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

204 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:** 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 **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 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.*