Files
crowd-funder-for-time-pwa/doc/plan-background-jwt-pool-and-expiry.md
Jose Olarte III c9ea2e4120 docs: plan background JWT pool/expiry and plugin configureNativeFetcher handoff
Add plan-background-jwt-pool-and-expiry.md (Phase A/B, expiryDays + buffer sizing,
pool size 100). Add plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md
for daily-notification-plugin: optional jwtTokens on configureNativeFetcher. Link plan
to plugin doc and endorser-jwt-background-prefetch-options.md.
2026-03-27 14:57:54 +08:00

11 KiB
Raw Blame History

Plan: Background New Activity JWT — extended expiry + token pool

Date: 2026-03-27 14:29 PST
Status: Draft for implementation
Audience: TimeSafari / crowd-funder developers
Related: doc/endorser-jwt-background-prefetch-options.md, android/.../TimeSafariNativeFetcher.java, src/services/notifications/nativeFetcherConfig.ts, src/libs/crypto/index.ts


1. Problem statement

Background prefetch for New Activity calls Endorser with a Bearer JWT configured via configureNativeFetcher. The token previously came from getHeaders()accessToken(), which used exp ≈ 60 seconds (src/libs/crypto/index.ts). Prefetch runs minutes later in WorkManager without JavaScript, so the JWT can be expired before the POST (JWT_VERIFY_FAILED).

Goals:

  1. Use JWTs whose exp covers the gap between last app-side configure and prefetch (and ideally days without opening the app).
  2. Optionally support a pool of distinct JWT strings so Endorser can enforce duplicate-JWT / one-time-use rules without breaking daily prefetch. Pool size should follow expiryDays + buffer (one distinct token per day over the JWT lifetime, plus headroom for retries / edge cases); implementation uses BACKGROUND_JWT_POOL_SIZE = 100 until policy changes.
  3. Keep pool size and expiry policy easy to change (constants / remote config later).

2. Guiding principles

Principle Implication
Background has no JS Token selection and HTTP must run in native (or plugin) code using persisted data.
Single source of truth for signing Continue using createEndorserJwtForDid (same keys as today); do not fork crypto in Java/Kotlin.
Configurable pool size One constant BACKGROUND_JWT_POOL_SIZE; currently 100. Size should satisfy ≥ expiryDays + buffer (see below).
Phased delivery Ship extended expiry first; add pool when server duplicate rules require it or in the same release if coordinated.

2.1 Pool size rationale (expiryDays + buffer)

For one New Activity prefetch per day, each day should use a distinct JWT string if the server rejects reuse. Over the JWT lifetime (aligned with exp), you need at least one token per day the pool might be used without regeneration.

Rule of thumb:

BACKGROUND_JWT_POOL_SIZE ≥ ceil(BACKGROUND_JWT_EXPIRY_DAYS) + BACKGROUND_JWT_POOL_BUFFER
  • BACKGROUND_JWT_EXPIRY_DAYS — human-facing match to exp (e.g. 90); convert to BACKGROUND_JWT_EXPIRY_SECONDS for the payload.
  • BACKGROUND_JWT_POOL_BUFFER — extra slots for same-day retries, manual tests, or stricter duplicate rules (e.g. 10).

Example: 90day exp + buffer 10 ⇒ minimum 100 logical slots. This plan keeps BACKGROUND_JWT_POOL_SIZE = 100 as the shipped default so it matches that example; if expiryDays or buffer change later, bump the constant so the inequality still holds.


3. Phases

Phase A — Extended expiry only (minimum viable)

Scope

  • Introduce a dedicated mint path for background / native fetcher use (name TBD, e.g. accessTokenForBackgroundNotifications(did)), producing one JWT per configure call with:

    • iss: DID (unchanged)
    • iat: now
    • exp: now + BACKGROUND_JWT_EXPIRY_SECONDS (derived from BACKGROUND_JWT_EXPIRY_DAYS; see §2.1 / Phase B constants — confirm with Endorser policy)
    • Optional: jti or nonce for uniqueness if needed for logging/debug
  • configureNativeFetcherIfReady should pass this token (or keep using a thin wrapper) instead of reusing the 60s accessToken() when configuring native fetcher onlydo not change interactive getHeaders() / passkey caching behavior for normal API calls unless product asks for it.

Files (likely)

  • src/libs/crypto/index.ts — new function or parameters; keep accessToken() default at 60s for existing callers.
  • src/services/notifications/nativeFetcherConfig.ts — obtain background JWT via the new mint path, not getHeaders()s generic path, or add a dedicated branch that calls the new mint after resolving did.

Native

  • TimeSafariNativeFetcher: still one jwtToken field; no pool yet. Ensure configure() is called whenever TS refreshes (startup, resume, Account — already partially covered).

Exit criteria

  • Logcat: prefetch POST returns 200 (or non-expired 4xx) when user has not opened the app for several minutes after configure.
  • Endorser accepts exp far enough in the future (coordinate TTL policy).

Phase B — Token pool (size 100; driven by expiryDays + buffer)

Why

  • Endorser may reject duplicate JWT strings (same bearer used twice). One long-lived token could fail on day 2 if the server marks each JWT as consumed.
  • A pool of N distinct JWTs (different payload, e.g. unique jti per token) gives N independent strings with the same long exp. N should follow §2.1 (expiryDays + buffer); 100 is the initial BACKGROUND_JWT_POOL_SIZE (satisfies e.g. 90 + 10).

Scope

  1. Constants (single place, e.g. src/constants/backgroundJwt.ts or next to native fetcher config):

    BACKGROUND_JWT_EXPIRY_DAYS = 90              // align with Endorser; drives exp
    BACKGROUND_JWT_EXPIRY_SECONDS = 90 * 24 * 60 * 60  // derived
    BACKGROUND_JWT_POOL_BUFFER = 10            // retries / headroom; tune with server team
    BACKGROUND_JWT_POOL_SIZE = 100               // must be >= expiryDays + buffer; adjust if policy changes
    
  2. Mint in TS (uses createEndorserJwtForDid):

    • Loop i = 0 .. POOL_SIZE - 1
    • Payload: { iss, iat, exp, jti: ${did}#bg#${i} or uuid }confirm jti format with Endorser if required.
  3. Persistence — native code must read the pool without JS:

    • Option B1 (preferred): Implement in @timesafari/daily-notification-plugin (not in the app): extend configureNativeFetcher to accept an optional JWT pool, persist it for native read. Handoff spec: doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md — copy or reference that file in the plugin repo PR.
    • Option B2 (app-only, no plugin release): Write JSON to Capacitor Preferences or encrypted storage from TS; TimeSafariNativeFetcher reads the same store on Android (requires knowing Capacitors Android SharedPreferences name/key convention or a tiny bridge in MainActivity). Use only if plugin work is deferred.
  4. Selection policy in TimeSafariNativeFetcher (before each POST):

    • By calendar day: index = (epochDay + offset) % POOL_SIZE (stable per day).
    • Or sequential: persist lastUsedIndex in prefs and increment (wrap). Decision: document chosen policy; day-based is easier to reason about for “one token per day.”
  5. configureNativeFetcherIfReady (and any “reset notifications on startup” hook):

    • Regenerate full pool when user opens app (per product decision), then call configure with pool + current apiBaseUrl / did.
  6. iOS: When iOS native fetcher exists, mirror Android behavior.

Exit criteria

  • Prefetch succeeds on consecutive days with duplicate-JWT enforcement enabled on a staging Endorser.
  • Pool refreshes on startup without breaking dual schedule.

4. Detailed tasks (checklist)

Crypto & TypeScript

  • Add BACKGROUND_JWT_EXPIRY_DAYS, BACKGROUND_JWT_EXPIRY_SECONDS, BACKGROUND_JWT_POOL_BUFFER, and BACKGROUND_JWT_POOL_SIZE (exported constants), with a comment that POOL_SIZE >= expiryDays + buffer (see §2.1).
  • Implement mintBackgroundJwtPool(did: string): Promise<string[]> (or split single + pool).
  • Ensure each JWT has unique jti (or equivalent) for duplicate detection.
  • Do not break existing accessToken() 60s behavior for unrelated features.
  • Wire configureNativeFetcherIfReady to pass single extended token (Phase A) then pool (Phase B).
  • On logout / identity clear, clear persisted pool and call plugin clear if needed.

Android

  • Phase A: No structural change if configure() still receives one string; verify non-null jwtToken after configure.
  • Phase B: Parse pool from persisted JSON; implement selectTokenForRequest(); use selected token in Authorization header instead of sole jwtToken field (keep configure for apiBaseUrl / did).
  • Unit or instrumentation tests optional: selection index deterministic.

Plugin (Option B1 — daily-notification-plugin repo)

  • Follow doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md (API shape, Android/iOS, versioning).
  • Release new plugin version; bump dependency in this app.

Product & server

  • Endorser: confirm max exp, duplicate JWT semantics, recommended jti format.
  • Document operational limit: if user never opens app for longer than exp allows (or longer than pool × daily use without refresh), prefetch may fail until next open — align with doc/endorser-jwt-background-prefetch-options.md.

5. Security notes

  • Longer-lived JWTs and many tokens increase impact if device is compromised. Mitigations: encrypted prefs where possible, no logging of full JWTs, revocation story with Endorser (key rotation, deny list).
  • Pool regeneration on login should replace old pools.

6. Testing plan

Test Expected
Configure → wait > 5 min → prefetch 200 from plansLastUpdatedBetween (Phase A)
Two consecutive days with duplicate-JWT staging 200 both days (Phase B)
Logout Pool cleared; no stale bearer
Lower BACKGROUND_JWT_POOL_SIZE in dev only (below expiryDays + buffer) Expect possible reuse / server duplicate errors — use to reproduce failures

7. Rollout / staging

  1. Implement Phase A behind feature flag optional (or direct if low risk).
  2. Verify on test-api.endorser.ch with server team.
  3. Phase B behind flag or same release once server duplicate rules are understood.

8. Where plugin documentation lives

Document Purpose
doc/plan-background-jwt-pool-and-expiry.md (this file) End-to-end app plan: crypto, pool sizing, native host, rollout.
doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md Plugin-only handoff: extend configureNativeFetcher, persist pool, Android/iOS notes — intended for PRs in daily-notification-plugin (or Cursor on that repo).

Keeping them separate avoids mixing consumer app tasks with plugin API contract; the plan links to the plugin feedback doc for Option B1.


9. References

Topic Location
Current 60s accessToken src/libs/crypto/index.ts
createEndorserJwtForDid src/libs/endorserServer.ts
Native configure src/services/notifications/nativeFetcherConfig.ts
Android HTTP android/.../TimeSafariNativeFetcher.java
Options doc (TTL, refresh, BFF) doc/endorser-jwt-background-prefetch-options.md
Plugin: configureNativeFetcher + JWT pool doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md

Update this plan when Phase A/B ship or when Endorser policy changes.