diff --git a/doc/plan-background-jwt-pool-and-expiry.md b/doc/plan-background-jwt-pool-and-expiry.md new file mode 100644 index 0000000000..5b1fe5173e --- /dev/null +++ b/doc/plan-background-jwt-pool-and-expiry.md @@ -0,0 +1,203 @@ +# 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:** + +```text +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:** 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`: 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 **only** — **do 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): + + ```text + 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 Capacitor’s 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` (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.* diff --git a/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md b/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md new file mode 100644 index 0000000000..ed361e6421 --- /dev/null +++ b/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md @@ -0,0 +1,95 @@ +# Plugin feedback: `configureNativeFetcher` — optional JWT pool for background API calls + +**Date:** 2026-03-27 PST +**Target repo:** `@timesafari/daily-notification-plugin` (daily-notification-plugin) +**Consuming app:** crowd-funder-for-time-pwa (TimeSafari) +**Related app plan:** `doc/plan-background-jwt-pool-and-expiry.md` (Phase B, Option B1) + +--- + +## Summary + +The host app’s **`NativeNotificationContentFetcher`** (`TimeSafariNativeFetcher` on Android) calls Endorser with a Bearer JWT set via **`configureNativeFetcher`**. For **background** prefetch, the token must stay valid until WorkManager runs (often **minutes later**); Endorser may also reject **duplicate** JWT strings across days. + +The **app** will mint a **pool** of distinct JWTs (see app plan) and needs the plugin to **accept and persist** that pool so native code can select a token **without JavaScript** at prefetch time. + +**Requested change (plugin):** extend **`configureNativeFetcher`** to accept an optional **JWT pool** alongside the existing **`jwtToken`**, persist it in the same storage the host already relies on (e.g. SharedPreferences / app group), and document how **`NativeNotificationContentFetcher`** implementations should read it. + +--- + +## Motivation + +| Issue | Why plugin support helps | +|-------|---------------------------| +| Single short-lived `jwtToken` | Expires before background fetch | +| Server duplicate-JWT rules | Need many distinct bearer strings over time | +| No JS in WorkManager | Pool must be readable **only** from native | + +--- + +## Proposed API (TypeScript / Capacitor) + +**Extend** existing `configureNativeFetcher` options (names indicative — align with plugin naming conventions): + +```ts +configureNativeFetcher(options: { + apiBaseUrl: string; + activeDid: string; + /** Primary token; keep for backward compatibility and Phase A (single long-lived JWT). */ + jwtToken: string; + /** + * Optional. Distinct JWT strings for background use (e.g. one per day slot). + * If omitted, behavior matches today (single jwtToken only). + */ + jwtTokens?: string[]; +}); +``` + +**Alternatives** (if size limits matter for bridge payload): + +- `jwtTokenPoolJson: string` — JSON array string of JWT strings (single string across the bridge). + +**Validation (plugin):** + +- If `jwtTokens` present: length **≤** a sane cap (host will use ~100; plugin may enforce max e.g. 128). +- Empty array: treat as “no pool” (same as omitting). + +--- + +## Android + +1. **Parse** new fields in `DailyNotificationPlugin.configureNativeFetcher` (or equivalent). +2. **Persist** pool under the same prefs namespace used for other TimeSafari / dual-schedule data, or a **documented** key prefix (e.g. `jwt_token_pool` as JSON array string). +3. **Document** for host implementers: `NativeNotificationContentFetcher` should: + - Prefer **pool entry** for `fetchContent` when pool is non-empty (selection policy is **host** responsibility — e.g. day index % length), **or** + - Expose a small helper the host fetcher calls to resolve “current” bearer. +4. **Clear** pool when `configureNativeFetcher` is called with a new identity / empty pool / logout path (coordinate with host). +5. **Backward compatibility:** if only `jwtToken` is sent, behavior **unchanged** from current release. + +--- + +## iOS + +When `configureNativeFetcher` exists on iOS, mirror Android: accept optional pool, persist, document read path for native fetcher. + +--- + +## Versioning & release + +- Bump **plugin semver** (minor: new optional fields). +- Publish package; consuming app bumps **`@timesafari/daily-notification-plugin`** and updates `nativeFetcherConfig.ts` to pass `jwtTokens` when Phase B ships. + +--- + +## References (host app) + +| Topic | Location | +|--------|----------| +| End-to-end plan (Phase A/B, pool sizing) | `doc/plan-background-jwt-pool-and-expiry.md` | +| Android fetcher | `android/.../TimeSafariNativeFetcher.java` | +| Current configure call | `src/services/notifications/nativeFetcherConfig.ts` | +| JWT options (expired token context) | `doc/endorser-jwt-background-prefetch-options.md` | + +--- + +*This document is intended to be copied or linked from PRs in **daily-notification-plugin**; keep app-specific details in the app plan.*