diff --git a/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java b/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java
index 926ba6f5..485502e0 100644
--- a/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java
+++ b/android/app/src/main/java/app/timesafari/TimeSafariNativeFetcher.java
@@ -98,7 +98,17 @@ public class TimeSafariNativeFetcher implements NativeNotificationContentFetcher
: ""));
}
- /** One pool entry per UTC day (epoch day mod pool size); else primary jwtToken. */
+ /**
+ * Picks the pool entry whose validity window covers today, falling back to the
+ * primary jwtToken when no pool is configured.
+ *
+ *
Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
+ * (mintBackgroundJwtTokenPool) files the token for a given day at index
+ * {@code epochDay % size}. That is why the index below is the raw epoch day rather
+ * than a count from when the pool arrived: this side keeps no mint date, and the
+ * same arithmetic on both ends is what lines the slot up with the day it covers.
+ * A token read from the wrong slot is outside its window and Endorser rejects it.
+ */
private String selectBearerTokenForRequest() {
List pool = jwtTokenPool;
if (pool == null || pool.isEmpty()) {
diff --git a/doc/background-jwt-pool.md b/doc/background-jwt-pool.md
new file mode 100644
index 00000000..8a5d1c8f
--- /dev/null
+++ b/doc/background-jwt-pool.md
@@ -0,0 +1,155 @@
+# Background New Activity JWT pool
+
+How the app credentials native background prefetch for New Activity.
+
+## 1. What the pool is for
+
+Background prefetch runs in WorkManager on Android and a background task on iOS,
+with no JavaScript executing. It calls Endorser directly and needs a Bearer JWT
+that was minted while the app was awake, possibly days earlier.
+
+The app mints a pool of `BACKGROUND_JWT_POOL_SIZE` JWTs and hands them to the
+plugin through `configureNativeFetcher`. Each covers one UTC day, and the native
+fetcher picks the one matching the day it runs.
+
+Source: `src/libs/crypto/backgroundJwtPool.ts`,
+`src/services/notifications/nativeFetcherConfig.ts`,
+`src/constants/backgroundJwt.ts`.
+
+## 2. Token shape
+
+Each token in the pool carries:
+
+| Claim | Value |
+|-------|-------|
+| `iss` | the minting DID |
+| `iat` | mint time |
+| `nbf` | its day's opening midnight, minus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
+| `exp` | its day's closing midnight, plus `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` |
+
+The slack widens the window at both ends for clock skew between the device and
+Endorser. Widening is safe; narrowing can leave a prefetch inside the day with
+no usable token.
+
+Two properties follow from the day windows, and both are load-bearing:
+
+- **Each token grants one day.** A token read from a log line or a captured
+ header buys one day of Endorser access rather than the whole grant.
+- **The tokens are distinct.** ES256K signing is deterministic, so JWTs built
+ from identical payloads are byte-identical. Differing windows are what keep
+ the pool from collapsing into one string repeated `POOL_SIZE` times, which
+ would defeat any duplicate-token rule the server applies.
+
+## 3. Slot ordering
+
+Both native fetchers select with `pool[epochDay % pool.size()]` and hold no
+record of when the pool was minted. The minter therefore files the token
+covering a given UTC day at index `epochDay % BACKGROUND_JWT_POOL_SIZE`.
+
+Any `POOL_SIZE` consecutive days hit every index exactly once, so the array is
+dense whatever day minting starts on.
+
+This is a contract across three languages. Changing the index arithmetic on one
+side without the others produces tokens presented outside their windows, which
+Endorser rejects with no local error. `src/test/backgroundJwtPool.test.ts`
+asserts the invariant by replaying the native selector against the minted pool.
+
+Implementations: `TimeSafariNativeFetcher.selectBearerTokenForRequest` in
+`android/app/src/main/java/app/timesafari/` and `ios/App/App/`.
+
+## 4. Identities that can mint
+
+Seed-phrase (`did:ethr`) identities only.
+
+Passkey (`did:peer`) identities raise
+`BackgroundJwtUnsupportedIdentityError`. Each of their signatures is a WebAuthn
+assertion, so minting a pool would raise one biometric prompt per token, and
+`createJwtNavigator` overrides the day window with a one-minute `exp` — the
+tokens would expire long before the prefetch they were minted for.
+`configureNativeFetcherIfReady` catches the error and leaves prefetch
+unconfigured.
+
+The delegated alertSearch batch rejects the same identities, with the
+notify-api answering `DELEGATED_JWT_UNSUPPORTED_IDENTITY`.
+
+## 5. Lifecycle
+
+| Event | Action |
+|-------|--------|
+| App foreground, startup, notification-time change | `configureNativeFetcherIfReady` mints a pool and configures the fetcher |
+| Active identity changes (`$setActiveDid`) | `clearNativeFetcherPool` drops the pool the fetcher holds |
+
+The identity is decrypted once per mint and reused for every signature.
+Decrypting per token costs seconds on a phone, and minting runs on every
+foreground.
+
+## 6. What the pool bounds
+
+The grant is `BACKGROUND_JWT_POOL_SIZE` days wide and each token inside it is
+one day wide.
+
+`clearNativeFetcherPool` is custody, not revocation. Endorser exposes no
+revocation mechanism, so a token that left the device before the clear stays
+valid until its window closes. What the clear bounds is the ordinary case — an
+account switch, a sign-out, a shared or lost handset — where no copy was taken
+and the device's own store is the only remaining exposure.
+
+Anti-replay in the strict sense is unavailable on this path. It would require
+either a server-side one-time-use store, which Endorser does not offer, or
+per-request signing, which would put the private key in native code. Day-scoped
+windows narrow the exposure instead of eliminating it.
+
+## 7. Constants
+
+All in `src/constants/backgroundJwt.ts`.
+
+| Constant | Meaning |
+|----------|---------|
+| `BACKGROUND_JWT_POOL_SIZE` | Consecutive UTC days the pool covers, one token each. The whole forward grant a user authorizes per mint. |
+| `BACKGROUND_JWT_WINDOW_SLACK_SECONDS` | Padding on each end of a day window, for clock skew. |
+| `BACKGROUND_JWT_SECONDS_PER_DAY` | The day frame each slot is cut from. |
+| `BACKGROUND_JWT_EXPIRY_DAYS` / `_SECONDS` | Lifetime for the single-token background path. |
+
+Past the last covered day the pool carries no credential and prefetch stops
+until the app opens again.
+
+## 8. A different credential
+
+The notify-api's delegated alertSearch batch
+(`src/services/notifications/alertAuthorizationBatch.ts`) is a separate
+credential with a separate inventory. It authorizes the notification service to
+run a user's daily alertSearch server-side; this pool authorizes the user's own
+device to prefetch. They share the day-window shape and nothing else. See
+`notification-wakeup-service/README.md`.
+
+## 9. Rejected
+
+- **A unique `jti` per slot, with one shared long `exp`.** A `jti` is an
+ identifier, not a replay defense: it does nothing unless the server keeps a
+ seen-set, and Endorser's behavior here was never confirmed. Day windows make
+ the tokens distinct for the same cost while also bounding each one.
+- **One long-lived token instead of a pool.** Fails if Endorser rejects
+ duplicate JWT strings across days. That policy question is open (§10), so the
+ design does not depend on the answer.
+- **Sizing the pool as `expiryDays + buffer`.** The rationale was headroom for
+ duplicate-token rules. With one token per day, the pool size is the grant
+ length in days and needs no separate buffer term.
+- **Per-request signing in native code (DPoP-style).** The only true anti-replay
+ option, rejected to keep one signing implementation in TypeScript rather than
+ forking crypto into Java and Swift.
+- **Routing all New Activity through the notification service and deleting this
+ path.** Rejected: it would force every user onto server-side delegation, and
+ the service is single-replica, so the direct device-to-Endorser path has no
+ equivalent.
+
+## 10. Open questions
+
+- **Endorser duplicate-JWT policy.** Whether Endorser rejects a Bearer JWT
+ string it has already seen is unconfirmed. The pool is correct either way; the
+ answer would determine whether a pool is required at all.
+- **Maximum `exp` Endorser accepts.** Day-scoped windows are well inside any
+ plausible limit, so this gates only the single-token path.
+- **Plugin behavior on an empty pool.** `clearNativeFetcherPool` passes empty
+ credentials to `configureNativeFetcher`. A plugin build that rejects them
+ leaves the previous pool in place; the failure is logged rather than reported
+ as a successful clear.
diff --git a/doc/plan-background-jwt-pool-and-expiry.md b/doc/plan-background-jwt-pool-and-expiry.md
deleted file mode 100644
index 5b1fe517..00000000
--- a/doc/plan-background-jwt-pool-and-expiry.md
+++ /dev/null
@@ -1,203 +0,0 @@
-# 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
index ed361e64..fa7f2357 100644
--- a/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md
+++ b/doc/plugin-feedback-daily-notification-configureNativeFetcher-jwt-pool.md
@@ -3,7 +3,7 @@
**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)
+**Related app plan:** `doc/background-jwt-pool.md`
---
@@ -85,7 +85,7 @@ When `configureNativeFetcher` exists on iOS, mirror Android: accept optional poo
| Topic | Location |
|--------|----------|
-| End-to-end plan (Phase A/B, pool sizing) | `doc/plan-background-jwt-pool-and-expiry.md` |
+| Pool design, slot ordering, lifecycle | `doc/background-jwt-pool.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` |
diff --git a/ios/App/App/TimeSafariNativeFetcher.swift b/ios/App/App/TimeSafariNativeFetcher.swift
index 75ed73d3..ffb5e5a5 100644
--- a/ios/App/App/TimeSafariNativeFetcher.swift
+++ b/ios/App/App/TimeSafariNativeFetcher.swift
@@ -38,7 +38,15 @@ final class TimeSafariNativeFetcher: NativeNotificationContentFetcher {
try await fetchContentWithRetry(context: context, retryCount: 0)
}
- /// One pool entry per UTC day (epoch day mod pool size); else primary `jwtToken` — same as Java.
+ /// Picks the pool entry whose validity window covers today, falling back to the
+ /// primary `jwtToken` when no pool is configured. Same arithmetic as Java.
+ ///
+ /// Each pooled JWT carries nbf/exp spanning exactly one UTC day, and the minter
+ /// (`mintBackgroundJwtTokenPool`) files the token for a given day at index
+ /// `epochDay % size`. That is why the index below is the raw epoch day rather than
+ /// a count from when the pool arrived: this side keeps no mint date, and the same
+ /// arithmetic on both ends is what lines the slot up with the day it covers.
+ /// A token read from the wrong slot is outside its window and Endorser rejects it.
private func selectBearerTokenForRequest() -> String? {
guard let pool = jwtTokenPool, !pool.isEmpty else { return jwtToken }
let epochDay = Int64(Date().timeIntervalSince1970 * 1000) / (24 * 60 * 60 * 1000)
diff --git a/src/constants/backgroundJwt.ts b/src/constants/backgroundJwt.ts
index ddce87fc..7da1f44b 100644
--- a/src/constants/backgroundJwt.ts
+++ b/src/constants/backgroundJwt.ts
@@ -1,15 +1,27 @@
/**
- * JWT lifetime for native New Activity background prefetch (`configureNativeFetcher`).
- * See doc/plan-background-jwt-pool-and-expiry.md. Confirm max `exp` with Endorser before raising.
+ * JWT lifetime for the single-token native background prefetch path
+ * (`accessTokenForBackgroundNotifications`). Confirm the maximum `exp` Endorser
+ * accepts before raising this.
*/
export const BACKGROUND_JWT_EXPIRY_DAYS = 90;
export const BACKGROUND_JWT_EXPIRY_SECONDS =
BACKGROUND_JWT_EXPIRY_DAYS * 24 * 60 * 60;
-/** Headroom for retries / tests; pool size should be ≥ expiryDays + buffer. */
-export const BACKGROUND_JWT_POOL_BUFFER = 10;
+/** Seconds in a UTC day; the frame every pool slot is cut from. */
+export const BACKGROUND_JWT_SECONDS_PER_DAY = 24 * 60 * 60;
-/** Distinct JWT strings minted per configure (duplicate-JWT / daily slot). */
-export const BACKGROUND_JWT_POOL_SIZE =
- BACKGROUND_JWT_EXPIRY_DAYS + BACKGROUND_JWT_POOL_BUFFER;
+/**
+ * Consecutive UTC days the native background prefetch pool covers, one JWT per
+ * day. This is the whole forward grant the user authorizes in a single mint:
+ * past the last day the pool carries no credential and prefetch stops until the
+ * app opens again. See `doc/background-jwt-pool.md`.
+ */
+export const BACKGROUND_JWT_POOL_SIZE = 100;
+
+/**
+ * Padding on each end of a day's validity window, for clock skew between the
+ * device and Endorser. Widening a window is safe; narrowing it can leave a
+ * prefetch inside the day with no usable token.
+ */
+export const BACKGROUND_JWT_WINDOW_SLACK_SECONDS = 5 * 60;
diff --git a/src/libs/crypto/backgroundJwtPool.ts b/src/libs/crypto/backgroundJwtPool.ts
new file mode 100644
index 00000000..aabe1308
--- /dev/null
+++ b/src/libs/crypto/backgroundJwtPool.ts
@@ -0,0 +1,93 @@
+/**
+ * Day-scoped JWT pool for native New Activity background prefetch.
+ *
+ * Kept apart from `@/libs/crypto` so minting depends only on the signer and the
+ * pool constants: the prefetch path is the one place in the app where a
+ * credential is handed to native code and used without any JavaScript running,
+ * and its dependencies should stay small enough to read in one sitting.
+ */
+
+import {
+ BACKGROUND_JWT_POOL_SIZE,
+ BACKGROUND_JWT_SECONDS_PER_DAY,
+ BACKGROUND_JWT_WINDOW_SLACK_SECONDS,
+} from "@/constants/backgroundJwt";
+import type { KeyMetaWithPrivate } from "@/interfaces/common";
+import { createEndorserJwtForKey } from "./vc";
+
+/** Thrown for identities whose keys cannot sign a day-scoped background pool. */
+export class BackgroundJwtUnsupportedIdentityError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "BackgroundJwtUnsupportedIdentityError";
+ }
+}
+
+/**
+ * Mint one JWT per UTC day for native background prefetch
+ * (`configureNativeFetcher` `jwtTokens`), covering the
+ * {@link BACKGROUND_JWT_POOL_SIZE} consecutive days starting with the day of
+ * the call.
+ *
+ * Each token is valid only for the single day it covers: `nbf` at that day's
+ * opening midnight and `exp` at its closing one, each widened by
+ * {@link BACKGROUND_JWT_WINDOW_SLACK_SECONDS} for clock skew. A token that
+ * escapes through a log line or a captured header therefore buys one day of
+ * Endorser access rather than the whole grant. The day windows are also what
+ * make the tokens distinct: the ES256K signer is deterministic, so JWTs built
+ * from identical payloads are byte-identical, and a pool of identical strings
+ * defeats any duplicate-token rule the server may apply.
+ *
+ * Slot order is a contract with the native selector, which reads
+ * `pool[epochDay % pool.size()]` and has no record of when the pool was minted:
+ * the token covering a UTC day sits at index `epochDay % POOL_SIZE`. Any
+ * POOL_SIZE consecutive days hit every index exactly once, so the array is
+ * dense whatever day minting starts on.
+ *
+ * The caller supplies the decrypted account rather than a DID: signing
+ * {@link BACKGROUND_JWT_POOL_SIZE} tokens from a DID would decrypt the identity
+ * once per token, which costs seconds on a phone and runs on every app
+ * foreground.
+ *
+ * Passkey (`did:peer`) identities cannot mint this pool -- each signature is a
+ * WebAuthn assertion, and `createJwtNavigator` overrides the day window with its
+ * own one-minute `exp` -- so they raise
+ * {@link BackgroundJwtUnsupportedIdentityError} instead of prompting
+ * {@link BACKGROUND_JWT_POOL_SIZE} times for tokens that expire before the
+ * prefetch they were minted for.
+ *
+ * @param account decrypted identity the pool is issued by and for
+ * @throws BackgroundJwtUnsupportedIdentityError for passkey identities
+ */
+export async function mintBackgroundJwtTokenPool(
+ account: KeyMetaWithPrivate,
+): Promise {
+ if (!account?.identity) {
+ throw new BackgroundJwtUnsupportedIdentityError(
+ `No day-scoped signing key for ${account?.did}; background prefetch ` +
+ "requires a seed-phrase identity.",
+ );
+ }
+ const did = account.did;
+ const nowEpoch = Math.floor(Date.now() / 1000);
+ const firstEpochDay = Math.floor(nowEpoch / BACKGROUND_JWT_SECONDS_PER_DAY);
+ const tokens: string[] = new Array(BACKGROUND_JWT_POOL_SIZE);
+ for (let offset = 0; offset < BACKGROUND_JWT_POOL_SIZE; offset++) {
+ const epochDay = firstEpochDay + offset;
+ const dayStartEpoch = epochDay * BACKGROUND_JWT_SECONDS_PER_DAY;
+ const tokenPayload = {
+ nbf: dayStartEpoch - BACKGROUND_JWT_WINDOW_SLACK_SECONDS,
+ exp:
+ dayStartEpoch +
+ BACKGROUND_JWT_SECONDS_PER_DAY +
+ BACKGROUND_JWT_WINDOW_SLACK_SECONDS,
+ iat: nowEpoch,
+ iss: did,
+ };
+ tokens[epochDay % BACKGROUND_JWT_POOL_SIZE] = await createEndorserJwtForKey(
+ account,
+ tokenPayload,
+ );
+ }
+ return tokens;
+}
diff --git a/src/libs/crypto/index.ts b/src/libs/crypto/index.ts
index 93d92ada..b8ff2d57 100644
--- a/src/libs/crypto/index.ts
+++ b/src/libs/crypto/index.ts
@@ -4,10 +4,6 @@ import { entropyToMnemonic } from "ethereum-cryptography/bip39";
import { wordlist } from "ethereum-cryptography/bip39/wordlists/english";
import { HDNode } from "@ethersproject/hdnode";
-import {
- BACKGROUND_JWT_EXPIRY_SECONDS,
- BACKGROUND_JWT_POOL_SIZE,
-} from "@/constants/backgroundJwt";
import {
CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI,
createEndorserJwtForDid,
@@ -108,45 +104,6 @@ export const accessToken = async (did?: string) => {
}
};
-/**
- * JWT for native New Activity prefetch (`configureNativeFetcher` / WorkManager).
- * Uses a long `exp` (`BACKGROUND_JWT_EXPIRY_SECONDS`); do not use for ordinary
- * in-app API calls — use `getHeaders` / `accessToken` instead.
- */
-export const accessTokenForBackgroundNotifications = async (
- did?: string,
-): Promise => {
- if (!did) {
- return "";
- }
- const nowEpoch = Math.floor(Date.now() / 1000);
- const endEpoch = nowEpoch + BACKGROUND_JWT_EXPIRY_SECONDS;
- const tokenPayload = { exp: endEpoch, iat: nowEpoch, iss: did };
- return createEndorserJwtForDid(did, tokenPayload);
-};
-
-/**
- * Mint {@link BACKGROUND_JWT_POOL_SIZE} distinct JWTs for native background prefetch
- * (`configureNativeFetcher` `jwtTokens`). Unique `jti` per slot; same `exp` for all.
- */
-export async function mintBackgroundJwtTokenPool(
- did: string,
-): Promise {
- const nowEpoch = Math.floor(Date.now() / 1000);
- const endEpoch = nowEpoch + BACKGROUND_JWT_EXPIRY_SECONDS;
- const tokens: string[] = [];
- for (let i = 0; i < BACKGROUND_JWT_POOL_SIZE; i++) {
- const tokenPayload = {
- exp: endEpoch,
- iat: nowEpoch,
- iss: did,
- jti: `${did}#bg#${i}`,
- };
- tokens.push(await createEndorserJwtForDid(did, tokenPayload));
- }
- return tokens;
-}
-
/**
* Extract JWT from various URL formats
* @param jwtUrlText The URL containing the JWT
diff --git a/src/services/notifications/nativeFetcherConfig.ts b/src/services/notifications/nativeFetcherConfig.ts
index 719a249d..aeaffb4d 100644
--- a/src/services/notifications/nativeFetcherConfig.ts
+++ b/src/services/notifications/nativeFetcherConfig.ts
@@ -8,10 +8,16 @@
import { Capacitor } from "@capacitor/core";
import { DailyNotification } from "@/plugins/DailyNotificationPlugin";
-import { mintBackgroundJwtTokenPool } from "@/libs/crypto";
+import {
+ BackgroundJwtUnsupportedIdentityError,
+ mintBackgroundJwtTokenPool,
+} from "@/libs/crypto/backgroundJwtPool";
+import { retrieveFullyDecryptedAccount } from "@/libs/util";
+import type { KeyMetaWithPrivate } from "@/interfaces/common";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { logger } from "@/utils/logger";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
+import { BACKGROUND_JWT_SECONDS_PER_DAY } from "@/constants/backgroundJwt";
import { onNotificationAuthMayBeReady } from "./notificationAuthLifecycle";
/**
@@ -64,8 +70,39 @@ export async function configureNativeFetcherIfReady(
: DEFAULT_ENDORSER_API_SERVER;
}
- const jwtTokens = await mintBackgroundJwtTokenPool(did);
- const jwtToken = jwtTokens[0] ?? "";
+ // Decrypt once here rather than once per token inside the minter.
+ const account = await retrieveFullyDecryptedAccount(did);
+ if (!account) {
+ logger.debug(
+ "[nativeFetcherConfig] No account for active DID; skipping native fetcher config",
+ );
+ return false;
+ }
+
+ let jwtTokens: string[];
+ try {
+ jwtTokens = await mintBackgroundJwtTokenPool(
+ account as KeyMetaWithPrivate,
+ );
+ } catch (error) {
+ if (error instanceof BackgroundJwtUnsupportedIdentityError) {
+ // Passkey identities: minting would raise one WebAuthn prompt per token
+ // and yield one-minute tokens. Leave prefetch unconfigured instead.
+ logger.debug(
+ "[nativeFetcherConfig] Identity cannot mint a background pool; " +
+ "skipping native fetcher config",
+ );
+ return false;
+ }
+ throw error;
+ }
+ // Pool slots are keyed by `epochDay % size`, not by distance from the mint,
+ // so the standalone `jwtToken` a plugin without pool support falls back to
+ // has to be read at today's slot rather than at index 0.
+ const todayEpochDay = Math.floor(
+ Date.now() / 1000 / BACKGROUND_JWT_SECONDS_PER_DAY,
+ );
+ const jwtToken = jwtTokens[todayEpochDay % jwtTokens.length] ?? "";
if (!jwtToken) {
logger.warn(
"[nativeFetcherConfig] No JWT for native fetcher; API-driven notifications may fail",
@@ -98,3 +135,51 @@ export async function configureNativeFetcherIfReady(
return false;
}
}
+
+/**
+ * Drop the JWT pool the native fetcher holds, so a signed-out or switched-away
+ * identity leaves no usable credential on the device.
+ *
+ * This is custody, not revocation: the tokens stay cryptographically valid
+ * until their windows close, and Endorser offers nothing that would invalidate
+ * a copy taken before this call. What it bounds is the ordinary case -- account
+ * switch, sign-out, a shared or lost handset -- where no copy was taken and the
+ * only remaining risk is the device's own store. A cleared pool leaves each
+ * selector with an empty bearer, which both refuse to send.
+ *
+ * Call before the active identity changes; `configureNativeFetcherIfReady`
+ * mints a fresh pool for whichever identity takes over.
+ *
+ * @returns true when the pool was cleared or there was nothing to clear
+ */
+export async function clearNativeFetcherPool(): Promise {
+ if (!Capacitor.isNativePlatform()) {
+ return false;
+ }
+ if (!DailyNotification?.configureNativeFetcher) {
+ logger.warn(
+ "[nativeFetcherConfig] Plugin configureNativeFetcher not available; " +
+ "cannot clear the native JWT pool",
+ );
+ return false;
+ }
+ try {
+ await DailyNotification.configureNativeFetcher({
+ apiBaseUrl: DEFAULT_ENDORSER_API_SERVER,
+ activeDid: "",
+ jwtToken: "",
+ jwtTokens: [],
+ });
+ logger.info("[nativeFetcherConfig] Native fetcher JWT pool cleared");
+ return true;
+ } catch (error) {
+ // A plugin build that rejects empty credentials leaves the old pool in
+ // place; say so rather than reporting a clear that did not happen.
+ logger.error(
+ "[nativeFetcherConfig] Could not clear the native JWT pool; " +
+ "the previous identity's tokens remain on the device:",
+ error,
+ );
+ return false;
+ }
+}
diff --git a/src/test/backgroundJwtPool.test.ts b/src/test/backgroundJwtPool.test.ts
new file mode 100644
index 00000000..5d187c7f
--- /dev/null
+++ b/src/test/backgroundJwtPool.test.ts
@@ -0,0 +1,144 @@
+import {
+ BACKGROUND_JWT_POOL_SIZE,
+ BACKGROUND_JWT_SECONDS_PER_DAY,
+} from "@/constants/backgroundJwt";
+import {
+ BackgroundJwtUnsupportedIdentityError,
+ mintBackgroundJwtTokenPool,
+} from "@/libs/crypto/backgroundJwtPool";
+import { createEndorserJwtForKey } from "@/libs/crypto/vc";
+import type { KeyMetaWithPrivate } from "@/interfaces/common";
+
+jest.mock("@/libs/crypto/vc", () => ({ createEndorserJwtForKey: jest.fn() }));
+
+const mockedSign = createEndorserJwtForKey as unknown as jest.Mock;
+
+const DID = "did:ethr:0x0000000000000000000000000000000000000001";
+
+const seedAccount = {
+ did: DID,
+ identity: JSON.stringify({ keys: [{ privateKeyHex: "ab".repeat(32) }] }),
+} as unknown as KeyMetaWithPrivate;
+
+const passkeyAccount = {
+ did: "did:peer:0zAbC",
+ passkeyCredIdHex: "beef",
+} as unknown as KeyMetaWithPrivate;
+
+interface DayWindow {
+ nbf: number;
+ exp: number;
+}
+
+/**
+ * The signer is mocked, so each slot carries back the window it was minted with
+ * instead of a JWT. Distinctness of the real strings rests on these windows
+ * differing: ES256K signing is deterministic, so equal payloads sign to equal
+ * bytes.
+ */
+function windowsFromMint(tokens: string[]): DayWindow[] {
+ return tokens.map((t) => JSON.parse(t) as DayWindow);
+}
+
+/** The selector both native fetchers run, in Java and Swift alike. */
+function selectBearerTokenForRequest(
+ pool: string[],
+ atEpochMs: number,
+): string {
+ const epochDay = Math.floor(atEpochMs / (24 * 60 * 60 * 1000));
+ return pool[epochDay % pool.length];
+}
+
+beforeEach(() => {
+ mockedSign.mockReset();
+ mockedSign.mockImplementation(async (_account, payload) =>
+ JSON.stringify(payload),
+ );
+});
+
+describe("mintBackgroundJwtTokenPool", () => {
+ // 23:47 UTC: a mint late in the day must still file today's token in today's
+ // slot, and the pool must not run a day short at the far end.
+ const MINTED_AT = Date.UTC(2026, 8, 14, 23, 47, 3);
+
+ function mintAt(epochMs: number): Promise {
+ jest.spyOn(Date, "now").mockReturnValue(epochMs);
+ return mintBackgroundJwtTokenPool(seedAccount);
+ }
+
+ afterEach(() => jest.restoreAllMocks());
+
+ it("fills every slot exactly once", async () => {
+ const pool = await mintAt(MINTED_AT);
+ expect(pool).toHaveLength(BACKGROUND_JWT_POOL_SIZE);
+ expect(pool.filter(Boolean)).toHaveLength(BACKGROUND_JWT_POOL_SIZE);
+ });
+
+ it("gives every slot a distinct day window", async () => {
+ const pool = await mintAt(MINTED_AT);
+ expect(new Set(pool).size).toBe(BACKGROUND_JWT_POOL_SIZE);
+ });
+
+ it("scopes each token to one day rather than the whole grant", async () => {
+ const windows = windowsFromMint(await mintAt(MINTED_AT));
+ for (const w of windows) {
+ const span = w.exp - w.nbf;
+ expect(span).toBeLessThan(2 * BACKGROUND_JWT_SECONDS_PER_DAY);
+ expect(span).toBeGreaterThan(BACKGROUND_JWT_SECONDS_PER_DAY);
+ }
+ });
+
+ /**
+ * The invariant the native selectors depend on. Both read
+ * `pool[epochDay % size]` and hold no mint date, so the token filed in a slot
+ * has to be the one whose window covers the day that lands on it. A pool
+ * ordered by distance-from-mint passes every other test here and fails this
+ * one -- silently, in production, as rejected prefetches.
+ */
+ it("files each day's token where the native selector looks for it", async () => {
+ const pool = await mintAt(MINTED_AT);
+ const firstDayStart =
+ Math.floor(MINTED_AT / 1000 / BACKGROUND_JWT_SECONDS_PER_DAY) *
+ BACKGROUND_JWT_SECONDS_PER_DAY;
+
+ for (let offset = 0; offset < BACKGROUND_JWT_POOL_SIZE; offset++) {
+ // midday on each covered day, well inside any clock-skew slack
+ const atSec = firstDayStart + offset * BACKGROUND_JWT_SECONDS_PER_DAY;
+ for (const probe of [atSec, atSec + BACKGROUND_JWT_SECONDS_PER_DAY - 1]) {
+ const picked = selectBearerTokenForRequest(pool, probe * 1000);
+ const w = JSON.parse(picked) as DayWindow;
+ expect(probe).toBeGreaterThanOrEqual(w.nbf);
+ expect(probe).toBeLessThanOrEqual(w.exp);
+ }
+ }
+ });
+
+ it("holds the slot mapping whatever day minting starts on", async () => {
+ // Every residue mod the pool size, so no start day is a lucky one.
+ for (const dayOffset of [0, 1, 37, BACKGROUND_JWT_POOL_SIZE - 1]) {
+ const at = MINTED_AT + dayOffset * 24 * 60 * 60 * 1000;
+ const pool = await mintAt(at);
+ const picked = selectBearerTokenForRequest(pool, at);
+ const w = JSON.parse(picked) as DayWindow;
+ const nowSec = Math.floor(at / 1000);
+ expect(nowSec).toBeGreaterThanOrEqual(w.nbf);
+ expect(nowSec).toBeLessThanOrEqual(w.exp);
+ jest.restoreAllMocks();
+ }
+ });
+
+ it("refuses passkey identities instead of prompting once per token", async () => {
+ await expect(mintBackgroundJwtTokenPool(passkeyAccount)).rejects.toThrow(
+ BackgroundJwtUnsupportedIdentityError,
+ );
+ expect(mockedSign).not.toHaveBeenCalled();
+ });
+
+ it("decrypts the identity once, not once per token", async () => {
+ const pool = await mintAt(MINTED_AT);
+ expect(mockedSign).toHaveBeenCalledTimes(pool.length);
+ for (const call of mockedSign.mock.calls) {
+ expect(call[0]).toBe(seedAccount);
+ }
+ });
+});
diff --git a/src/utils/PlatformServiceMixin.ts b/src/utils/PlatformServiceMixin.ts
index 96acb244..b23e69bf 100644
--- a/src/utils/PlatformServiceMixin.ts
+++ b/src/utils/PlatformServiceMixin.ts
@@ -782,6 +782,23 @@ export const PlatformServiceMixin = {
* Required for smart deletion pattern
*/
async $setActiveDid(did: string | null): Promise {
+ // The outgoing identity's background-prefetch JWTs cover days ahead and
+ // stay valid on their own; drop them here so they do not outlive the
+ // identity that authorized them. Imported lazily to keep the native
+ // notification stack out of the bundle for every component using this
+ // mixin. A failure to clear is logged inside and must not block the
+ // switch, which is why this is not awaited into the caller's failure path.
+ try {
+ const { clearNativeFetcherPool } = await import(
+ "@/services/notifications/nativeFetcherConfig"
+ );
+ await clearNativeFetcherPool();
+ } catch (error) {
+ logger.warn(
+ "[PlatformServiceMixin] Native JWT pool clear failed",
+ error,
+ );
+ }
await this.$dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[did],