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.
11 KiB
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:
- Use JWTs whose
expcovers the gap between last app-side configure and prefetch (and ideally days without opening the app). - 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 usesBACKGROUND_JWT_POOL_SIZE = 100until policy changes. - 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 toexp(e.g. 90); convert toBACKGROUND_JWT_EXPIRY_SECONDSfor the payload.BACKGROUND_JWT_POOL_BUFFER— extra slots for same-day retries, manual tests, or stricter duplicate rules (e.g. 10).
Example: 90‑day 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: nowexp: now +BACKGROUND_JWT_EXPIRY_SECONDS(derived fromBACKGROUND_JWT_EXPIRY_DAYS; see §2.1 / Phase B constants — confirm with Endorser policy)- Optional:
jtior nonce for uniqueness if needed for logging/debug
-
configureNativeFetcherIfReadyshould pass this token (or keep using a thin wrapper) instead of reusing the 60saccessToken()when configuring native fetcher only — do not change interactivegetHeaders()/ passkey caching behavior for normal API calls unless product asks for it.
Files (likely)
src/libs/crypto/index.ts— new function or parameters; keepaccessToken()default at 60s for existing callers.src/services/notifications/nativeFetcherConfig.ts— obtain background JWT via the new mint path, notgetHeaders()’s generic path, or add a dedicated branch that calls the new mint after resolvingdid.
Native
TimeSafariNativeFetcher: still onejwtTokenfield; no pool yet. Ensureconfigure()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
expfar 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
jtiper token) gives N independent strings with the same longexp. N should follow §2.1 (expiryDays + buffer); 100 is the initialBACKGROUND_JWT_POOL_SIZE(satisfies e.g. 90 + 10).
Scope
-
Constants (single place, e.g.
src/constants/backgroundJwt.tsor 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 -
Mint in TS (uses
createEndorserJwtForDid):- Loop
i = 0 .. POOL_SIZE - 1 - Payload:
{ iss, iat, exp, jti:${did}#bg#${i}or uuid }— confirmjtiformat with Endorser if required.
- Loop
-
Persistence — native code must read the pool without JS:
- Option B1 (preferred): Implement in
@timesafari/daily-notification-plugin(not in the app): extendconfigureNativeFetcherto 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 Capacitor’s Android
SharedPreferencesname/key convention or a tiny bridge inMainActivity). Use only if plugin work is deferred.
- Option B1 (preferred): Implement in
-
Selection policy in
TimeSafariNativeFetcher(before each POST):- By calendar day:
index = (epochDay + offset) % POOL_SIZE(stable per day). - Or sequential: persist
lastUsedIndexin prefs and increment (wrap). Decision: document chosen policy; day-based is easier to reason about for “one token per day.”
- By calendar day:
-
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.
- Regenerate full pool when user opens app (per product decision), then call configure with pool + current
-
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, andBACKGROUND_JWT_POOL_SIZE(exported constants), with a comment thatPOOL_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
configureNativeFetcherIfReadyto 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-nulljwtTokenafter configure. - Phase B: Parse pool from persisted JSON; implement
selectTokenForRequest(); use selected token inAuthorizationheader instead of solejwtTokenfield (keepconfigureforapiBaseUrl/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, recommendedjtiformat. - Document operational limit: if user never opens app for longer than
expallows (or longer than pool × daily use without refresh), prefetch may fail until next open — align withdoc/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
- Implement Phase A behind feature flag optional (or direct if low risk).
- Verify on test-api.endorser.ch with server team.
- 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.