From 1d7169b2666f9241295a23e36ade5f150149739b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:31:15 +0100 Subject: [PATCH 01/60] refactor(pwa): drop the service worker's API data cache The worker buffered every allowlisted API read, the 60-second version poll included, and wrote it to CacheStorage before the page got it. No page could ever be opened offline to read those answers back, since every signed-in document is no-store. What the cache did do: keep one account's readings on a shared device, answer a refetch that failed right after a save with the copy from before it, and let a late write recreate the cache after the sign-out wipe. API requests and the hashed /_next/static/ chunks now go to the network untouched; the HTTP cache already holds the immutable chunks. The cache-name pattern keeps `data`, so caches older releases left behind are still deleted on activate and by the session wipe. The TypeScript copy of the allowlist and its test go with it, and the record-switch e2e spec now checks that nothing under /api/ is kept on disk before or after the switch. --- e2e/v137-record-session-fence-offline.spec.ts | 401 ++---------------- public/sw.js | 160 +------ .../baselines/test-source-reads.json | 5 +- .../baselines/version-stamp-comments.json | 10 +- src/__tests__/service-worker.test.ts | 173 +++----- src/lib/pwa/__tests__/sw-cache-policy.test.ts | 149 ------- src/lib/pwa/query-persister.ts | 28 +- src/lib/pwa/sw-cache-policy.ts | 77 +--- 8 files changed, 132 insertions(+), 871 deletions(-) delete mode 100644 src/lib/pwa/__tests__/sw-cache-policy.test.ts diff --git a/e2e/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts index 455df1581..23984fe43 100644 --- a/e2e/v137-record-session-fence-offline.spec.ts +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -4,45 +4,22 @@ import { expect, test } from "./setup/test"; import { FENCE_OFFLINE_STORAGE_STATE_PATH } from "./setup/test-helpers"; /** - * FENCE-AC-07 — the disk layers cannot serve one record's bytes inside - * another. + * FENCE-AC-07: no record's API answers are kept in CacheStorage, before or + * after a record switch. * - * ## Why this is a spec of its own + * Runs in the `chromium-service-worker` project with the worker allowed and + * no route mocks, because the claim is about what the worker itself stores. + * The worker passes every `/api/` request to the network and keeps none of + * them; the only per-account layer on disk is the IndexedDB snapshot, which + * refuses to write or restore inside another record (unit-tested in + * `query-persister-shared-record.test.ts`). * - * `playwright.config.ts` sets `serviceWorkers: "block"` for every other - * project, and for a good reason written there: a worker-originated `fetch` is - * not subject to `page.route`, so the SW would bypass the per-spec route mocks. - * - * This case is the exact inverse. Its whole claim is about what the - * `healthlog-data-*` cache can and cannot serve across a record switch — and - * with the worker blocked that cache is never populated at all, so the positive - * control ("the owner snapshot really WAS on disk before the switch") could - * never pass no matter what the application did. A check that cannot pass is - * worse than no check, so this runs in the `chromium-service-worker` project - * with the worker allowed, and uses no route mocks. - * - * ## What is asserted, in order - * - * 1. the owner's record reads really are on disk before the switch — a test - * that seeded nothing proves nothing; - * 2. only current-version caches exist, which is the eviction the fence's - * design relies on to make "a response with no echo" safe; - * 3. the switch empties the data cache before it reloads; - * 4. every response now on disk carries the TARGET's record scope and never - * the previous one — the claim, read off the disk layer itself rather - * than inferred from what a page did or did not paint; - * 5. and offline, that cache is what answers a record read, still carrying - * the target's context. The non-empty scope set is the paired positive - * control: an empty cache would satisfy "no previous-record bytes" while - * proving nothing. + * The positive control is the page cache: the worker is running and storing + * something (public pages, catalogs, icons), so "no API entry" is not the + * same sentence as "the worker did nothing". */ -/** - * Pages whose reads the service worker is configured to hold on disk - * (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`, - * `/api/measurements` and `/api/medications`). Visited rather than fetched, so - * the requests are the app's own and carry the record-session assertion. - */ +/** Pages whose reads used to be kept on disk. Visited, so the reads are the app's own. */ const WARM_PAGES = ["/", "/measurements", "/medications"] as const; /** @@ -105,45 +82,27 @@ async function expectShellReady(page: Page) { }); } -/** What the `healthlog-data-*` caches hold right now. */ -async function dataCacheState(page: Page) { +/** Every HealthLog cache and the API answers it holds. */ +async function cacheState(page: Page) { return page.evaluate(async () => { - const names = await caches.keys(); - const data = names.filter((n) => n.startsWith("healthlog-data-")); + const names = (await caches.keys()).filter((n) => + n.startsWith("healthlog-"), + ); + const apiEntries: string[] = []; let entries = 0; - for (const name of data) { - entries += (await (await caches.open(name)).keys()).length; - } - return { names, dataCaches: data, entries }; - }); -} - -/** - * The record context every cached response was stored WITH. - * - * This is the disk-layer claim, read off the disk layer. A cached record - * response carries the echo it was served under, so if a previous record's - * bytes were still reachable they would show up here as that record's scope. - */ -async function cachedScopes(page: Page): Promise { - return page.evaluate(async () => { - const scopes: string[] = []; - for (const name of (await caches.keys()).filter((n) => - n.startsWith("healthlog-data-"), - )) { - const cache = await caches.open(name); - for (const request of await cache.keys()) { - const cached = await cache.match(request); - const scope = cached?.headers.get("x-healthlog-record-scope"); - if (scope) scopes.push(scope); + for (const name of names) { + for (const request of await (await caches.open(name)).keys()) { + entries += 1; + const { pathname } = new URL(request.url); + if (pathname.startsWith("/api/")) apiEntries.push(pathname); } } - return scopes; + return { names, entries, apiEntries }; }); } test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { - test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({ + test("FENCE-AC-07 no record's API answers are kept on disk across a switch", async ({ browser, }) => { const context = await browser.newContext({ @@ -153,10 +112,6 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { try { await page.goto("/"); - // The gate is spelled out here rather than left inside a helper because - // the next act is a one-shot `page.evaluate`, and - // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source - // text between the navigation and the read. await expect( page.getByRole("button", { name: "User menu" }), ).toBeVisible(); @@ -168,148 +123,51 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { await page.locator('[data-slot="shared-record-banner-exit"]').click(); await expect(banner).toHaveCount(0); } - - // Wait for the worker to control the page — until it does, nothing it - // would cache is going through it. await page.waitForFunction( () => navigator.serviceWorker?.controller !== null, undefined, { timeout: 20_000 }, ); - // Warm the disk cache by VISITING the pages, not by issuing bare - // fetches. A bare `fetch` in `page.evaluate` carries no record-session - // assertion, so on a session that has been inside a shared record the - // fence refuses it and nothing cacheable comes back — the cache filled - // from the app's own reads instead, and those were the ones that happened - // to resolve no record scope. Driving the app is what puts real, - // echo-carrying record responses on disk. for (const path of WARM_PAGES) { await page.goto(path); // Spelled out rather than left inside `expectShellReady`, because the // next act is a one-shot `page.evaluate` and // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source - // text between the navigation and the read. A gate hidden in a helper - // is invisible to that guard and to the next reader. + // text between the navigation and the read. await expect( page.getByRole("button", { name: "User menu" }), ).toBeVisible(); await expectShellReady(page); } + + // POSITIVE CONTROL: the worker controls the page and keeps something. await expect - .poll(async () => (await dataCacheState(page)).entries, { + .poll(async () => (await cacheState(page)).entries, { timeout: 15_000, }) .toBeGreaterThan(0); - - // (1) POSITIVE CONTROL: the owner's bytes really are on disk. - const seeded = await dataCacheState(page); - expect(seeded.dataCaches.length).toBeGreaterThan(0); - expect(seeded.entries).toBeGreaterThan(0); - - // (2) Only current-version caches survive activation. This is the - // eviction the fence's "a response with no echo is served normally" rule - // depends on: a PRE-fence cached response carries no echo, and it can - // only be served if its cache is still there. - const version = seeded.dataCaches[0].replace("healthlog-data-", ""); - const stale = seeded.names.filter( - (n) => - /^healthlog-(static|pages|data)-/.test(n) && !n.endsWith(version), + const own = await cacheState(page); + expect(own.apiEntries).toEqual([]); + expect(own.names.filter((n) => n.startsWith("healthlog-data-"))).toEqual( + [], ); - expect(stale).toEqual([]); - await openSwitcher(page); - const entry = page.locator( - `[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`, - ); - await expect(entry).toBeVisible(); - const targetAccountId = await entry.getAttribute("data-account-id"); - expect(targetAccountId).not.toBeNull(); - await entry.click(); - await expect( - page.locator('[data-slot="shared-record-banner"]'), - ).toBeVisible(); - - // (3) NOT asserted: "the data cache is momentarily empty". - // - // The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch` - // runs before the reload — but the reload immediately refills them from - // the target's own reads, so an empty cache is a state that exists for a - // few milliseconds and cannot be sampled reliably. Polling for it made - // this case fail for a reason that had nothing to do with the claim. - // - // The claim is asserted directly below instead, and it is the stronger - // statement anyway: what matters is not that the cache was briefly empty - // but that nothing in it belongs to the previous record. - - // Visit inside the TARGET record so there is something of its own on - // disk to find. + await enterRecord(page); for (const path of WARM_PAGES) { await page.goto(path); - // Spelled out rather than left inside `expectShellReady`, because the - // next act is a one-shot `page.evaluate` and - // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source - // text between the navigation and the read. A gate hidden in a helper - // is invisible to that guard and to the next reader. await expect( page.getByRole("button", { name: "User menu" }), ).toBeVisible(); await expectShellReady(page); } - await expect - .poll(async () => (await dataCacheState(page)).entries, { - timeout: 15_000, - }) - .toBeGreaterThan(0); - // (4) + (5) The disk layer now holds the TARGET's bytes and only those, - // read off the disk layer itself. - // - // The first draft asserted the shared-record banner after an offline - // reload. That could never pass and had nothing to do with the claim: - // `/api/auth/me` is not a cached read, so offline it fails, `useAuth` - // errors and the shell paints its offline gate rather than a banner. The - // banner's absence says nothing about what is on disk. - // - // What IS on disk is checkable, and it is the whole point: every cached - // record response carries the echo it was served under, so a previous - // record's bytes surviving the switch would appear here as that record's - // scope. - const scopes = await cachedScopes(page); - // POSITIVE CONTROL: something really is cached with a context, so the - // assertion below is about a non-empty set. - expect(scopes.length).toBeGreaterThan(0); - for (const scope of scopes) { - expect(scope).toBe(targetAccountId); - } - expect(scopes).not.toContain("self"); - - // And the target's own record read really is one of the entries on disk, - // so the scope assertion above is about the reads that matter rather - // than about some incidental cached response. - const cachedUrls = await page.evaluate(async () => { - const urls: string[] = []; - for (const name of (await caches.keys()).filter((n) => - n.startsWith("healthlog-data-"), - )) { - for (const request of await (await caches.open(name)).keys()) { - urls.push(new URL(request.url).pathname); - } - } - return urls; - }); - expect(cachedUrls).toContain("/api/measurements"); - - // NOT asserted: that a bare `fetch` from page context can read those - // bytes back while offline. Since v1.37.0 an echoing response also - // carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and - // the Cache API honours `Vary` — so a request WITHOUT the fence headers - // does not match the stored entry at all. That is the correct behaviour - // and a second line of defence worth naming: bytes cached under one - // record context cannot be retrieved by a request asserting a different - // one, or none. The app's own reads carry the headers and do match. + const inside = await cacheState(page); + expect(inside.apiEntries).toEqual([]); + expect( + inside.names.filter((n) => n.startsWith("healthlog-data-")), + ).toEqual([]); } finally { - await context.setOffline(false); // Hand the shared session back to its own record; see the note in // `v137-record-session-fence.spec.ts`. await context.request @@ -318,185 +176,4 @@ test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { await context.close(); } }); - - test("FENCE-AC-07 a cached record response cannot be SERVED under a different context", async ({ - browser, - }) => { - /** - * The serving path, which the case above does not exercise. - * - * The test above asserts what is IN the cache. That is worth asserting and - * it is not the same claim: an entry can sit on disk under one context and - * still be handed to a request made under another, and on the EXTERNAL - * switch path nothing wipes it — the app never initiated the switch, so - * `clearOfflineCachesForRecordSwitch` never runs. On that path `Vary` is - * the only thing standing between a cached delegated response and the next - * request for the same URL. - * - * So this asks the Cache API the question a service worker asks it: - * `cache.match(request)` honours the stored response's `Vary`, so a request - * whose fence headers differ from the ones the entry was stored with does - * not match at all. Remove the `Vary` block from `attachRecordContextEcho` - * and the second lookup starts hitting. - */ - const context = await browser.newContext({ - storageState: FENCE_OFFLINE_STORAGE_STATE_PATH, - }); - const page = await context.newPage(); - - try { - await page.goto("/"); - await expect( - page.getByRole("button", { name: "User menu" }), - ).toBeVisible(); - await expectShellReady(page); - const banner = page.locator('[data-slot="shared-record-banner"]'); - if ((await banner.count()) > 0) { - await page.locator('[data-slot="shared-record-banner-exit"]').click(); - await expect(banner).toHaveCount(0); - } - await page.waitForFunction( - () => navigator.serviceWorker?.controller !== null, - undefined, - { timeout: 20_000 }, - ); - - // Enter the record and let its reads land on disk. - await enterRecord(page); - await page.goto("/measurements"); - await expect( - page.getByRole("button", { name: "User menu" }), - ).toBeVisible(); - await expectShellReady(page); - - // The context those bytes were stored under, read off the entry itself. - // - // Polled, not sampled once. The service worker writes its `healthlog-data-*` - // entry AFTER the response has been handed to the page, so - // `expectShellReady` returning is not evidence that the write has landed — - // it is evidence that the read arrived. Sampling here read `null` on a - // loaded machine and failed the positive control below, which then said - // "there is no cache entry" about a cache that was about to have one. - // Waiting on the entry itself waits for the signal rather than for a - // number of milliseconds, and it still fails if the entry never appears. - const readStoredEntry = async () => - page.evaluate(async () => { - for (const name of (await caches.keys()).filter((n) => - n.startsWith("healthlog-data-"), - )) { - const cache = await caches.open(name); - for (const request of await cache.keys()) { - if ( - !new URL(request.url).pathname.startsWith("/api/measurements") - ) - continue; - const response = await cache.match(request); - if (!response) continue; - return { - cacheName: name, - url: request.url, - vary: response.headers.get("Vary"), - epoch: response.headers.get("x-healthlog-record-epoch"), - scope: response.headers.get("x-healthlog-record-scope"), - }; - } - } - return null; - }); - - // POSITIVE CONTROL: there really is an entry, stored with a context. - await expect.poll(readStoredEntry, { timeout: 20_000 }).not.toBeNull(); - const stored = await readStoredEntry(); - expect(stored).not.toBeNull(); - expect(stored!.epoch).toMatch(/^\d+$/); - expect(stored!.scope).not.toBe("self"); - - // The EXTERNAL switch: nothing in the app initiates it, so - // `clearOfflineCachesForRecordSwitch` never runs and the entry above - // survives — the positive control below is what proves that. - // - // Sent with the page's own `fetch` rather than through - // `context.request`. Both are outside the app: the switch UI is what runs - // the wipe, and a bare `fetch` is not it (the service worker returns - // without touching any non-GET, so the request reaches the network - // unaltered). What differs is the socket. Every Playwright API context in - // the runner shares one process-wide keep-alive agent, so this POST could - // be handed a connection the server had already closed on its five-second - // idle timeout, and a POST is never replayed on a fresh one — twice this - // line ended the spec with `read ECONNRESET`, on all three attempts. - const left = await page.evaluate(async () => { - const res = await fetch("/api/account/switch", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ accountId: null }), - }); - return res.status; - }); - expect(left).toBe(200); - // Read back through the page for the same reason the switch above goes - // that way. This was the last call in the file still using the shared - // API context, and it duly failed with `read ECONNRESET` on all three - // attempts once the pooled socket had gone stale. The browser opens its - // own connection, and a GET for the caller's own session is outside the - // app either way. - const now = (await page.evaluate(async () => { - const res = await fetch("/api/auth/me"); - return (await res.json()).data.recordSession; - })) as { - epoch: number; - scope: string | null; - }; - // The context genuinely moved, or the comparison below is vacuous. - expect(String(now.epoch)).not.toBe(stored!.epoch); - - const lookups = await page.evaluate( - async ({ - cacheName, - url, - storedEpoch, - storedScope, - nowEpoch, - nowScope, - }) => { - const cache = await caches.open(cacheName); - const ask = async (epoch: string, scope: string) => - (await cache.match( - new Request(url, { - headers: { - "x-healthlog-record-epoch": epoch, - "x-healthlog-record-scope": scope, - }, - }), - )) !== undefined; - return { - underStoredContext: await ask(storedEpoch, storedScope), - underNewContext: await ask(nowEpoch, nowScope), - withNoAssertion: - (await cache.match(new Request(url))) !== undefined, - }; - }, - { - cacheName: stored!.cacheName, - url: stored!.url, - storedEpoch: stored!.epoch!, - storedScope: stored!.scope!, - nowEpoch: String(now.epoch), - nowScope: now.scope ?? "self", - }, - ); - - // POSITIVE CONTROL: the entry IS servable to the context it belongs to. - // Without this, "the other context misses" would pass for an entry that - // was simply not there. - expect(lookups.underStoredContext).toBe(true); - // The claim. Remove the `Vary` and this starts hitting. - expect(lookups.underNewContext).toBe(false); - expect(lookups.withNoAssertion).toBe(false); - } finally { - await context.request - .post("/api/account/switch", { data: { accountId: null } }) - .catch(() => {}); - await context.close(); - } - }); }); diff --git a/public/sw.js b/public/sw.js index 19178159e..6dbeb9995 100644 --- a/public/sw.js +++ b/public/sw.js @@ -39,28 +39,12 @@ const CACHE_VERSION = /* @sw-version-fallback */ "v1.47.2"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; -// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` -// reads. Network-first: online callers always get the fresh server response -// (and the cache is refreshed behind it), so a read-after-write never serves a -// stale pre-mutation list. Offline (the network fetch throws) the LAST cached -// dashboard/series is served instead of empty skeletons forever. -// -// v1.18.6.x — reverted from stale-while-revalidate, which served the cached -// pre-mutation copy first and only revalidated in the background, so a list -// read straight after a create/update showed the old (or empty) data until the -// next paint. Network-first restores read-after-write freshness while keeping -// the offline fallback. -const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`; -// Keep this ownership contract aligned with -// `clearOfflineCachesForSessionEnd()` in `query-persister.ts`. Cache cleanup -// must cover every current/legacy HealthLog cache without deleting unrelated -// CacheStorage namespaces owned by another app on the same origin. +// Every HealthLog cache, current and legacy, and nothing another app on the +// origin owns. `data` stays in the pattern so caches from releases that still +// kept API reads are deleted on activate. Same pattern as +// `HEALTHLOG_CACHE_NAME_RE` in `src/lib/pwa/sw-cache-policy.ts`. const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; -const CURRENT_HEALTHLOG_CACHES = new Set([ - STATIC_CACHE, - PAGE_CACHE, - DATA_CACHE, -]); +const CURRENT_HEALTHLOG_CACHES = new Set([STATIC_CACHE, PAGE_CACHE]); // Presence marker `src/proxy.ts` sets on every HealthLog page response. See // `isForeignNavigationResponse` for why the worker needs it and the proxy for // why it carries no version. Keep the name in lockstep with @@ -78,58 +62,6 @@ let foreignOrigin = false; let foreignOriginCheck = null; const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; -const MAX_DATA_ENTRIES = 60; - -// Safe-GET read allowlist. ONLY idempotent reads that render the core views -// and carry NO secret-shaped body. Auth, mutations and token endpoints are -// never listed — and `isCacheableApiResponse` is a second, body-level guard -// (mirrors the idempotency cache's `hlk_`/`hlr_`/`sk-` refusal) so a future -// endpoint that slipped onto the list can still never persist a secret. -// Matched by exact path or `/` / `?` prefix. -// v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`) -// are deliberately NOT cached: `/api/insights` prefix-matched the Coach chat -// endpoint into the disk cache, and neither belongs on a self-hosted health -// PWA's durable storage. Only the dashboard snapshot, the widget layout, the -// measurement reads (incl. the batched daily series), the medication reads, -// and the version probe are eligible. -const API_READ_ALLOWLIST = [ - "/api/dashboard/snapshot", - // v1.37.20 — daily digest: the Today rail's offline read. Keep in - // lockstep with src/lib/pwa/sw-cache-policy.ts. - "/api/daily/digest", - "/api/dashboard/widgets", - "/api/measurements", - "/api/medications", - "/api/version", -]; - -// Defence in depth: even an allowlisted path is refused if its pathname looks -// like an auth/token/secret surface. -const API_DENY_RE = /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i; - -function isAllowlistedApiRead(pathname) { - if (API_DENY_RE.test(pathname)) return false; - return API_READ_ALLOWLIST.some( - (p) => - pathname === p || - pathname.startsWith(p + "/") || - pathname.startsWith(p + "?"), - ); -} - -// Body-level secret refusal: never persist a body carrying a token/secret- -// shaped pattern even if the path was allowlisted by mistake. -const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/; - -function isCacheableApiResponse(response, bodyText) { - if (!response || !response.ok) return false; - const cacheControl = response.headers.get("Cache-Control") || ""; - if (/no-store/i.test(cacheControl)) return false; - if (typeof bodyText === "string" && SECRET_BODY_RE.test(bodyText)) { - return false; - } - return true; -} // Public immutable assets to precache on install. Never include `/`: installing // while signed in would fetch the authenticated Dashboard document with the @@ -213,22 +145,9 @@ self.addEventListener("fetch", (event) => { return; } - // API calls. Allowlisted safe GET reads use network-first so the installed - // PWA always renders fresh server data online (and falls back to the last - // cached copy only when the network fails); everything else (auth, - // mutations, anything not on the list) stays network-only. - if (url.pathname.startsWith("/api/")) { - if (isAllowlistedApiRead(url.pathname)) { - event.respondWith(networkFirstApi(request)); - } - return; - } - - // Next.js static assets — cache-first (immutable hashed filenames) - if (url.pathname.startsWith("/_next/static/")) { - event.respondWith(cacheFirst(request, STATIC_CACHE)); - return; - } + // Every other API request goes to the network untouched: no API answer is + // kept on this device. + if (url.pathname.startsWith("/api/")) return; // Locale-catalog boot script — cache-first. The URL is versioned by the // catalogs' content hash (`/i18n/?v=`, `next.config.ts`), @@ -275,60 +194,6 @@ async function cacheFirst(request, cacheName) { return response; } -/** - * Network-first for allowlisted safe GET `/api/*` reads. - * - * Always tries the network first so an online caller gets the fresh server - * response — critical for read-after-write: a list fetched straight after a - * create/update must reflect the mutation, not a stale pre-mutation copy. On a - * cacheable response the data cache is refreshed behind it. Only when the - * network fails (offline) is the last cached copy served; with neither, a JSON - * 503 lets the client's TanStack Query cache / IndexedDB persister supply data. - * - * Caching is gated twice: `isAllowlistedApiRead` already filtered the path, - * and `isCacheableApiResponse` inspects the actual body so a `no-store` or - * secret-shaped (`hlk_`/`hlr_`/`sk-`) response is never persisted — the same - * refusal discipline the idempotency cache uses. - */ -async function networkFirstApi(request) { - - try { - const response = await fetch(request); - // Read the body once to body-screen it, then reconstruct a fresh Response - // so both the cache.put and the return value have an unconsumed body. - const bodyText = await response - .clone() - .text() - .catch(() => undefined); - if (isCacheableApiResponse(response, bodyText)) { - await bestEffortCacheWrite( - DATA_CACHE, - request, - new Response(bodyText, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }), - MAX_DATA_ENTRIES, - ); - } - return response; - } catch { - // Network failed (offline) — serve the last cached copy if we have one. - const cache = await caches.open(DATA_CACHE); - const cached = await cache.match(request); - if (cached) return cached; - - return new Response( - JSON.stringify({ data: null, error: "offline", meta: { offline: true } }), - { - status: 503, - headers: { "Content-Type": "application/json; charset=utf-8" }, - }, - ); - } -} - /** * Privacy gate for the navigation page cache. Most app routes are * client-fetch-only shells with no server-rendered PII (health JSON loads @@ -348,8 +213,7 @@ async function networkFirstApi(request) { * account's record into the HTML. Only the public pages (sign-in, legal) * land in this cache, so a navigation to an app page without a connection * gets the offline page below, never somebody's cached record. A page that - * is already open keeps working offline from the cached chunks and the - * allowlisted data reads. + * is already open keeps what it has in memory. */ function isCacheableNavigation(request, response) { const cacheControl = response.headers.get("Cache-Control") || ""; @@ -369,9 +233,9 @@ function isCacheableNavigation(request, response) { * The worker is registered with scope `/` because HealthLog serves everything * from `/`. When HealthLog is stopped and a different app starts on the same * origin (the usual case is `localhost:3000` on a developer machine), this - * worker kept answering: `/_next/static/*` is cache-first, so the other app - * ran on HealthLog's cached chunks, and the only way out was unregistering by - * hand in the browser's developer tools. + * worker kept answering from HealthLog's caches (chunks, at the time), and + * the only way out was unregistering by hand in the browser's developer + * tools. * * Only a response that positively looks like another app counts: * diff --git a/src/__tests__/baselines/test-source-reads.json b/src/__tests__/baselines/test-source-reads.json index ff3077a13..638367e64 100644 --- a/src/__tests__/baselines/test-source-reads.json +++ b/src/__tests__/baselines/test-source-reads.json @@ -1,7 +1,7 @@ { "$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.", - "count": 337, - "total": 337, + "count": 336, + "total": 336, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 1, "__tests__/admin-data-wipe-completeness.test.ts": 1, @@ -334,7 +334,6 @@ "lib/modules/__tests__/surface-module-registry.test.ts": 1, "lib/mood/__tests__/context-vocabulary.test.ts": 1, "lib/notifications/__tests__/event-defaults.test.ts": 1, - "lib/pwa/__tests__/sw-cache-policy.test.ts": 1, "lib/record-activity/__tests__/verb-map-covers-routes.test.ts": 1, "lib/sharing/__tests__/record-session-fence.test.ts": 1, "lib/sources/__tests__/source-priority-whoop-applehealth.test.ts": 1, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..4e39c2009 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2892, - "total": 9220, + "count": 2890, + "total": 9211, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -55,7 +55,7 @@ "__tests__/recurrence-course-anchor-guard.test.ts": 1, "__tests__/relation-links-backup-completeness.test.ts": 1, "__tests__/resolved-field-consumer-guard.test.ts": 1, - "__tests__/service-worker.test.ts": 2, + "__tests__/service-worker.test.ts": 1, "__tests__/session-surface-guard.test.ts": 7, "__tests__/settings-legacy-routes.test.ts": 1, "__tests__/shared-record-navigation.test.tsx": 1, @@ -2680,10 +2680,8 @@ "lib/profile/life-circumstances.ts": 1, "lib/profile/life-derived.ts": 1, "lib/profile/life-report-rows.ts": 1, - "lib/pwa/__tests__/sw-cache-policy.test.ts": 3, "lib/pwa/query-client-options.ts": 1, - "lib/pwa/query-persister.ts": 3, - "lib/pwa/sw-cache-policy.ts": 2, + "lib/pwa/query-persister.ts": 2, "lib/queries/__tests__/use-achievements-query.test.tsx": 1, "lib/queries/__tests__/use-dashboard-snapshot.test.tsx": 4, "lib/queries/__tests__/use-medication-compliance-summary.test.ts": 1, diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index a62b16f78..301642f89 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -1,19 +1,20 @@ /** - * v1.15.20 — service-worker cache behaviour, evaluated in a `vm` sandbox. + * Service-worker behaviour, evaluated in a `vm` sandbox. * * `public/sw.js` is plain script (no module system), so the test boots it * inside a vm context with a fake CacheStorage and dispatches the captured - * event listeners. Four behaviours are pinned: + * event listeners. Pinned here: * - * 1. cache write and trim failures never replace a successful network - * response for API, navigation, or static-asset requests; - * 2. the offline HTML fallback only serves a shell cached under the - * CURRENT `CACHE_VERSION` cache names (a stale pre-update shell would - * reference a chunk graph that no longer exists); - * 3. `activate` enables navigation preload and `networkFirst` consumes - * `event.preloadResponse` when the browser supplies it; - * 4. `trimCache` reads the key list once and deletes the excess prefix - * in a single pass (the previous loop re-fetched all keys per delete). + * 1. API requests and `/_next/static/` chunks go to the network untouched, + * and nothing of them lands in CacheStorage; + * 2. cache write and trim failures never replace a successful network + * response for navigation or static-asset requests; + * 3. the offline HTML fallback only serves a page cached under the + * CURRENT `CACHE_VERSION` cache names; + * 4. `activate` deletes every other HealthLog cache, enables navigation + * preload, and `networkFirst` consumes `event.preloadResponse`; + * 5. `trimCache` reads the key list once and deletes the excess prefix + * in a single pass. * * A HealthLog page response carries the `X-HealthLog-Page` marker the proxy * sets, so the navigation fixtures below build pages with `healthlogPage()`. @@ -41,7 +42,10 @@ const FALLBACK_VERSION = /\/\* @sw-version-fallback \*\/\s*"(v[^"]*)"/.exec( )![1]; const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`; const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`; -const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`; +// The API cache older releases kept, under the current version's name. +const LEGACY_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`; +// A cache-first static asset: the locale catalog boot script. +const CATALOG = "/i18n/en.js?v=abc123"; const PAGE_MARKER = "X-HealthLog-Page"; @@ -277,18 +281,6 @@ function dispatchNavigationFetch( return captured; } -function dispatchApiFetch(harness: SwHarness, path: string): Promise { - let captured: Promise | null = null; - harness.listeners.get("fetch")!({ - request: new Request(`${ORIGIN}${path}`), - respondWith: (p: Promise) => { - captured = p; - }, - }); - if (!captured) throw new Error("fetch handler did not respond"); - return captured; -} - function dispatchAssetFetch( harness: SwHarness, path: string, @@ -386,43 +378,37 @@ describe("sw.js — one-shot auth navigations", () => { await dispatchAuthNavigation(harness, url).response; await Promise.all(harness.lifetimePromises); - const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); - expect(await pages.match(`${ORIGIN}${url}`)).toBeUndefined(); - const data = await harness.cacheStorage.open(CURRENT_DATA_CACHE); - expect(await data.match(`${ORIGIN}${url}`)).toBeUndefined(); + expect(await harness.cacheStorage.match(`${ORIGIN}${url}`)).toBeUndefined(); + }); +}); + +describe("sw.js — what stays off the device", () => { + it.each([ + "/api/dashboard/snapshot", + "/api/measurements?type=WEIGHT", + "/api/medications", + "/api/daily/digest", + "/api/version", + "/_next/static/chunks/app.js", + ])("leaves %s to the network and stores nothing", async (path) => { + const harness = bootServiceWorker(); + let fetchCalls = 0; + harness.context.fetch = async () => { + fetchCalls += 1; + return new Response(JSON.stringify({ data: { weightKg: 80 } })); + }; + + const { responded } = dispatchAnyFetch(harness, path); + await Promise.all(harness.lifetimePromises); + + // Declined: the browser fetches it itself, the worker never sees the body. + expect(responded).toBe(false); + expect(fetchCalls).toBe(0); + expect(harness.cacheStorage.stores.size).toBe(0); }); }); describe("sw.js — best-effort cache writes", () => { - it("returns the original successful API response when cache.put rejects", async () => { - const harness = bootServiceWorker(); - const dataCache = await harness.cacheStorage.open(CURRENT_DATA_CACHE); - dataCache.putError = new Error("CacheStorage quota exceeded"); - const networkResponse = new Response( - JSON.stringify({ data: { weightKg: 80 }, error: null }), - { - status: 200, - headers: { - "Content-Type": "application/json", - "X-Network-Response": "original", - }, - }, - ); - harness.context.fetch = async () => networkResponse; - - const response = await dispatchApiFetch( - harness, - "/api/measurements?type=WEIGHT", - ); - - expect(response).toBe(networkResponse); - expect(response.status).toBe(200); - expect(response.headers.get("X-Network-Response")).toBe("original"); - expect(await response.text()).toBe( - JSON.stringify({ data: { weightKg: 80 }, error: null }), - ); - }); - it("returns the original successful navigation response when cache.put rejects", async () => { const harness = bootServiceWorker(); const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); @@ -477,39 +463,16 @@ describe("sw.js — best-effort cache writes", () => { const harness = bootServiceWorker(); const staticCache = await harness.cacheStorage.open(CURRENT_STATIC_CACHE); staticCache.putError = new Error("CacheStorage quota exceeded"); - const networkResponse = new Response("immutable chunk", { + const networkResponse = new Response("immutable catalog", { headers: { "X-Network-Response": "original" }, }); harness.context.fetch = async () => networkResponse; - const response = await dispatchAssetFetch( - harness, - "/_next/static/chunks/app.js", - ); + const response = await dispatchAssetFetch(harness, CATALOG); expect(response).toBe(networkResponse); expect(response.headers.get("X-Network-Response")).toBe("original"); - expect(await response.text()).toBe("immutable chunk"); - }); - - it("returns the original successful API response when cache trimming rejects", async () => { - const harness = bootServiceWorker(); - const dataCache = await harness.cacheStorage.open(CURRENT_DATA_CACHE); - dataCache.keysError = new Error("CacheStorage keys unavailable"); - const networkResponse = new Response( - JSON.stringify({ data: { weightKg: 80 }, error: null }), - { headers: { "Content-Type": "application/json" } }, - ); - harness.context.fetch = async () => networkResponse; - - const response = await dispatchApiFetch(harness, "/api/measurements"); - - expect(response).toBe(networkResponse); - expect(response.status).toBe(200); - expect(await response.text()).toBe( - JSON.stringify({ data: { weightKg: 80 }, error: null }), - ); - expect(dataCache.keysCalls).toBe(1); + expect(await response.text()).toBe("immutable catalog"); }); }); @@ -519,13 +482,16 @@ describe("sw.js — activate", () => { const stale = await harness.cacheStorage.open("healthlog-pages-v0.0.1"); await stale.put(`${ORIGIN}/`, new Response("stale shell")); await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + // API answers an older worker kept, under this version's name and an old one. + await harness.cacheStorage.open(LEGACY_DATA_CACHE); + await harness.cacheStorage.open("healthlog-data-v0.0.1"); + await harness.cacheStorage.open("next-app-runtime"); await dispatchActivate(harness); - expect(harness.cacheStorage.stores.has("healthlog-pages-v0.0.1")).toBe( - false, + expect([...harness.cacheStorage.stores.keys()].sort()).toEqual( + [CURRENT_PAGE_CACHE, "next-app-runtime"].sort(), ); - expect(harness.cacheStorage.stores.has(CURRENT_PAGE_CACHE)).toBe(true); expect(harness.navPreload.enabled).toBe(true); }); }); @@ -734,10 +700,7 @@ const foreignHtml = () => async function seedHealthlogCaches(harness: SwHarness): Promise { const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE); - await statics.put( - `${ORIGIN}/_next/static/chunks/app.js`, - new Response("healthlog chunk"), - ); + await statics.put(`${ORIGIN}${CATALOG}`, new Response("healthlog catalog")); const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); await pages.put(`${ORIGIN}/`, healthlogPage("healthlog shell")); const legacy = await harness.cacheStorage.open("healthlog-data-v0.0.1"); @@ -759,9 +722,9 @@ describe("sw.js — foreign origin (#847)", () => { expect(await res.text()).toBe("fresh shell"); expect(harness.unregisterCalls.count).toBe(0); expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); - const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js"); + const asset = dispatchAnyFetch(harness, CATALOG); expect(asset.responded).toBe(true); - expect(await (await asset.response!).text()).toBe("healthlog chunk"); + expect(await (await asset.response!).text()).toBe("healthlog catalog"); }); it("retires when the page lacks the marker and the version probe finds another app's HTML", async () => { @@ -775,9 +738,7 @@ describe("sw.js — foreign origin (#847)", () => { // The other app's page is returned untouched, and the worker declines // requests at once, before the probe has answered. expect(res).toBe(foreignPage); - expect( - dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, - ).toBe(false); + expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(false); await Promise.all(harness.lifetimePromises); @@ -790,9 +751,7 @@ describe("sw.js — foreign origin (#847)", () => { // Still declining after the retire: static, HTML and API all go to the // network through the browser. - expect( - dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, - ).toBe(false); + expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(false); expect( dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded, ).toBe(false); @@ -877,10 +836,10 @@ describe("sw.js — foreign origin (#847)", () => { expect(harness.unregisterCalls.count).toBe(0); expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); - // Normal service resumed: the cached chunk is served cache-first again. - const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js"); + // Normal service resumed: the cached catalog is served cache-first again. + const asset = dispatchAnyFetch(harness, CATALOG); expect(asset.responded).toBe(true); - expect(await (await asset.response!).text()).toBe("healthlog chunk"); + expect(await (await asset.response!).text()).toBe("healthlog catalog"); }); it.each([ @@ -905,9 +864,7 @@ describe("sw.js — foreign origin (#847)", () => { expect(harness.unregisterCalls.count).toBe(0); expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); - expect( - dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, - ).toBe(true); + expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(true); }, ); @@ -970,9 +927,7 @@ describe("sw.js — foreign origin (#847)", () => { expect([...harness.cacheStorage.stores.keys()]).toEqual([ "next-app-runtime", ]); - expect( - dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, - ).toBe(false); + expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(false); expect( dispatchAnyFetch(harness, "/about", { accept: "text/html" }).responded, ).toBe(false); @@ -1056,9 +1011,7 @@ describe("sw.js — foreign origin (#847)", () => { expect(res).toBe(response); expect(harness.unregisterCalls.count).toBe(0); - expect( - dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, - ).toBe(true); + expect(dispatchAnyFetch(harness, CATALOG).responded).toBe(true); }); it("does not treat an opaque redirect (a signed-out HealthLog page) as foreign", async () => { diff --git a/src/lib/pwa/__tests__/sw-cache-policy.test.ts b/src/lib/pwa/__tests__/sw-cache-policy.test.ts deleted file mode 100644 index f263fd7e8..000000000 --- a/src/lib/pwa/__tests__/sw-cache-policy.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { describe, expect, it } from "vitest"; - -import { - isAllowlistedApiRead, - isCacheableApiResponse, -} from "@/lib/pwa/sw-cache-policy"; - -const SW_SOURCE = readFileSync(join(process.cwd(), "public", "sw.js"), "utf8"); - -describe("isAllowlistedApiRead — offline read boundary", () => { - it("caches the curated safe GET read endpoints", () => { - expect(isAllowlistedApiRead("/api/dashboard/snapshot")).toBe(true); - expect(isAllowlistedApiRead("/api/dashboard/widgets")).toBe(true); - // v1.37.20 — the Today rail's offline read. Watched red before the - // allowlist entry landed. - expect(isAllowlistedApiRead("/api/daily/digest")).toBe(true); - expect(isAllowlistedApiRead("/api/measurements")).toBe(true); - expect( - isAllowlistedApiRead("/api/measurements/series-batch?types=WEIGHT"), - ).toBe(true); - expect(isAllowlistedApiRead("/api/medications")).toBe(true); - expect(isAllowlistedApiRead("/api/version")).toBe(true); - }); - - // v1.18.6 — AI/clinical narrative surfaces are never disk-cached. `/api/insights` - // previously prefix-matched the Coach chat list (`/api/insights/chat`, which - // returns decrypted conversation content) into the data cache; `/api/analytics` - // carries clinical aggregates. Both are off the allowlist. - it("never caches the AI / clinical narrative surfaces", () => { - expect(isAllowlistedApiRead("/api/insights")).toBe(false); - expect(isAllowlistedApiRead("/api/insights/chat")).toBe(false); - expect(isAllowlistedApiRead("/api/insights/generate")).toBe(false); - expect(isAllowlistedApiRead("/api/analytics")).toBe(false); - expect(isAllowlistedApiRead("/api/analytics/range")).toBe(false); - }); - - it("never caches auth / session / token / login surfaces", () => { - expect(isAllowlistedApiRead("/api/auth/me")).toBe(false); - expect(isAllowlistedApiRead("/api/auth/logout")).toBe(false); - expect(isAllowlistedApiRead("/api/sessions")).toBe(false); - expect(isAllowlistedApiRead("/api/tokens")).toBe(false); - expect(isAllowlistedApiRead("/api/login")).toBe(false); - expect(isAllowlistedApiRead("/api/webauthn/options")).toBe(false); - }); - - it("refuses an auth/token segment nested under an allowlisted prefix", () => { - expect(isAllowlistedApiRead("/api/medications/tokens")).toBe(false); - expect(isAllowlistedApiRead("/api/measurements/auth")).toBe(false); - }); - - it("does not cache endpoints outside the allowlist", () => { - expect(isAllowlistedApiRead("/api/admin/users")).toBe(false); - expect(isAllowlistedApiRead("/api/devices")).toBe(false); - expect(isAllowlistedApiRead("/api/withings/callback")).toBe(false); - }); - - it("never caches the cycle calendar feed or its management routes (#1350)", () => { - // The feed URL is a credential and its body is a record's cycle dates; - // neither belongs in a browser's durable storage. - const feed = `/api/cycle/feed/hlf_${"1f".repeat(24)}.ics`; - expect(isAllowlistedApiRead(feed)).toBe(false); - expect(isAllowlistedApiRead("/api/cycle/feed-subscription")).toBe(false); - expect(SW_SOURCE).not.toContain("/api/cycle"); - }); - - it("does not match a prefix that is only a partial path segment", () => { - // `/api/measurements-export` must NOT match `/api/measurements`. - expect(isAllowlistedApiRead("/api/measurements-export")).toBe(false); - }); -}); - -describe("isCacheableApiResponse — body/header refusal", () => { - it("caches a successful read with a plain body", () => { - expect( - isCacheableApiResponse({ - ok: true, - cacheControl: "private, max-age=0", - bodyText: JSON.stringify({ data: { weightKg: 80 }, error: null }), - }), - ).toBe(true); - }); - - it("refuses non-ok responses", () => { - expect(isCacheableApiResponse({ ok: false, bodyText: "{}" })).toBe(false); - }); - - it("refuses a no-store response", () => { - expect( - isCacheableApiResponse({ - ok: true, - cacheControl: "no-store", - bodyText: "{}", - }), - ).toBe(false); - }); - - it("refuses secret-shaped bodies (hlk_ / hlr_ / sk-)", () => { - expect( - isCacheableApiResponse({ - ok: true, - bodyText: JSON.stringify({ data: { token: "hlk_abc123def" } }), - }), - ).toBe(false); - expect( - isCacheableApiResponse({ - ok: true, - bodyText: JSON.stringify({ data: { refresh: "hlr_xyz789ghi" } }), - }), - ).toBe(false); - expect( - isCacheableApiResponse({ - ok: true, - bodyText: JSON.stringify({ data: { key: "sk-livesecret00" } }), - }), - ).toBe(false); - }); -}); - -// v1.18.6.x regression guard. The first v1.18.6 cut cached allowlisted API -// reads with stale-while-revalidate, which served the pre-mutation copy first -// and broke read-after-write (a list fetched straight after a create showed -// the old/empty data). The strategy must be network-first: always fetch fresh -// online, fall back to cache ONLY when the network throws (offline). -describe("public/sw.js — allowlisted API reads are network-first", () => { - it("routes the allowlisted API branch through a network-first handler", () => { - expect(SW_SOURCE).toContain("networkFirstApi(request)"); - expect(SW_SOURCE).toContain("async function networkFirstApi(request)"); - }); - - it("no longer uses stale-while-revalidate for API reads", () => { - expect(SW_SOURCE).not.toContain("staleWhileRevalidateApi"); - }); - - it("falls back to the cached copy only inside the network-failure catch", () => { - const handler = SW_SOURCE.slice( - SW_SOURCE.indexOf("async function networkFirstApi(request)"), - ); - const tryIdx = handler.indexOf("await fetch(request)"); - const catchIdx = handler.indexOf("} catch {"); - const cacheMatchIdx = handler.indexOf("cache.match(request)"); - // fetch comes first; the cache read lives after the catch boundary. - expect(tryIdx).toBeGreaterThan(-1); - expect(catchIdx).toBeGreaterThan(tryIdx); - expect(cacheMatchIdx).toBeGreaterThan(catchIdx); - }); -}); diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index 27a5fb67e..eed2a4b7d 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -339,9 +339,9 @@ export async function clearPersistedQueryCache(): Promise { } /** - * Wipe every client-side cache that can hold one account's health data: the - * IndexedDB query snapshot, the SW offline read-data cache, and the SW page - * cache (cached navigation HTML). The single call for a session END (logout or + * Wipe every client-side cache that can hold one account's data: the + * IndexedDB query snapshot and the worker's caches, including the API caches + * older releases left behind. The single call for a session END (logout or * 401 / expiry redirect). Best-effort across the board. */ export async function clearOfflineCachesForSessionEnd(): Promise { @@ -361,23 +361,15 @@ export async function clearOfflineCachesForSessionEnd(): Promise { } /** - * v1.36.0 — the same wipe, before a record switch in either direction. + * The same wipe, before a record switch in either direction. * - * Deliberately the same bytes as the session-end wipe rather than a narrower - * one, and deliberately named for the act that calls it. + * The full page reload the switch performs clears the in-memory cache; the + * IndexedDB snapshot survives it and holds the dashboard the browser was last + * looking at. Without this call the first paint after the switch could show + * the previous record's numbers under a banner naming the new one. * - * The full page reload the switch performs clears the in-memory cache; these - * two layers are the ones that survive it. The IndexedDB snapshot holds the - * dashboard the browser was last looking at, and the service worker's - * `healthlog-data-*` cache answers `/api/…` reads from disk on a - * stale-while-revalidate branch — so without this call, the first paint after - * switching into a record can be the previous record's numbers, served by the - * worker, under a banner naming the new one. Nothing about the page's own code - * would be wrong; the answer would just come from somewhere the page does not - * control. - * - * Run it BEFORE the reload and await it. A wipe racing the navigation is a - * wipe that sometimes does not happen, and "sometimes" is the whole failure. + * Run it BEFORE the reload and await it: a wipe racing the navigation + * sometimes does not happen. */ export async function clearOfflineCachesForRecordSwitch(): Promise { await clearOfflineCachesForSessionEnd(); diff --git a/src/lib/pwa/sw-cache-policy.ts b/src/lib/pwa/sw-cache-policy.ts index d8d2a805d..b379b849d 100644 --- a/src/lib/pwa/sw-cache-policy.ts +++ b/src/lib/pwa/sw-cache-policy.ts @@ -1,82 +1,9 @@ -/** - * Service-worker offline-read cache policy (v1.18.6). - * - * The runtime copy of this logic lives inline in `public/sw.js` (a service - * worker cannot import app modules at runtime). This module is the - * unit-tested source of truth for the safety boundary — the allowlist match, - * the auth/token deny guard, and the body-level secret refusal — so the SW's - * caching decisions are covered by `pnpm test`. Keep the two in lockstep when - * either changes. - * - * Boundary: ONLY idempotent GET reads that render the core views are cached. - * Auth/mutation/token surfaces are never cached, and a body carrying a - * secret-shaped token (`hlk_`/`hlr_`/`sk-`) is refused even if its path was - * allowlisted by mistake — mirroring the idempotency cache's refusal. - */ - /** * The worker's own cache names: `healthlog-{static,pages,data}-`. * Everything the app deletes from CacheStorage (session end, the "new * version" reload) matches against this and nothing else, so another app's - * caches on the same origin are left alone. Keep in lockstep with + * caches on the same origin are left alone. `data` names the API cache older + * releases kept; it stays so those caches are still deleted. Same pattern as * `HEALTHLOG_CACHE_NAME_RE` in `public/sw.js`. */ export const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; - -/** - * Path prefixes whose safe GET reads are eligible for the offline data cache. - * - * v1.18.6 — AI/clinical narrative surfaces (`/api/insights`, `/api/analytics`) - * are excluded: `/api/insights` prefix-matched the Coach chat endpoint - * (`/api/insights/chat`) into the disk cache, and neither belongs on durable - * storage. Only the dashboard snapshot, the widget layout, the measurement - * reads (incl. the batched daily series), the medication reads, and the - * version probe are cached. - */ -export const API_READ_ALLOWLIST = [ - "/api/dashboard/snapshot", - "/api/dashboard/widgets", - // v1.37.20 — the daily digest read: the Today rail is the first thing an - // offline reopen renders, and the digest is an idempotent GET with no - // secret in the body. The deny guard and the body-level refusal still - // apply on top, like every entry here. - "/api/daily/digest", - "/api/measurements", - "/api/medications", - "/api/version", -] as const; - -/** Auth/token surfaces refused even when nested under an allowlisted prefix. */ -export const API_DENY_RE = - /\/(auth|tokens?|sessions?|login|password|webauthn)(\/|$|\?)/i; - -/** Secret-shaped token patterns refused at the response-body level. */ -export const SECRET_BODY_RE = /(hlk_|hlr_|sk-)[A-Za-z0-9_-]/; - -export function isAllowlistedApiRead(pathname: string): boolean { - if (API_DENY_RE.test(pathname)) return false; - return API_READ_ALLOWLIST.some( - (p) => - pathname === p || - pathname.startsWith(p + "/") || - pathname.startsWith(p + "?"), - ); -} - -/** - * Decide whether an allowlisted response may be persisted. `ok` + no - * `no-store` + no secret-shaped body. Mirrors `isCacheableApiResponse` in - * `public/sw.js`. - */ -export function isCacheableApiResponse(opts: { - ok: boolean; - cacheControl?: string | null; - bodyText?: string; -}): boolean { - if (!opts.ok) return false; - if (/no-store/i.test(opts.cacheControl || "")) return false; - if (typeof opts.bodyText === "string" && SECRET_BODY_RE.test(opts.bodyText)) { - return false; - } - return true; -} From 0df65ce396c179dcf5058621e4b1d7a132a8b5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:33:57 +0100 Subject: [PATCH 02/60] fix(pwa): keep the open page when a link is tapped offline Without a connection the strip said "Showing your last synced data", but one tap on a tab or link traded the readable page for the worker's bare offline page: signed-in pages are no-store, so the document navigation Next falls back to has nothing to load. While the browser reports offline, a tap on a link to another page of the app now stays on the open page and repeats the hint in a toast. The strip says what is true: this page stays readable, other pages open once the connection is back. The README stops promising offline support. A cold start or a typed URL without a connection still gets the offline page; the e2e spec keeps that and adds the tap. --- README.md | 2 +- e2e/pwa-offline.spec.ts | 26 +++-- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/ko.json | 2 +- messages/nl.json | 2 +- messages/pl.json | 2 +- messages/pt-BR.json | 2 +- messages/ru.json | 2 +- .../baselines/version-stamp-comments.json | 6 +- .../layout/__tests__/offline-banner.test.tsx | 37 +++---- src/components/layout/offline-banner.tsx | 72 ++++++-------- .../pwa/__tests__/offline-navigation.test.ts | 96 +++++++++++++++++++ src/lib/pwa/offline-navigation.ts | 46 +++++++++ 17 files changed, 224 insertions(+), 81 deletions(-) create mode 100644 src/lib/pwa/__tests__/offline-navigation.test.ts create mode 100644 src/lib/pwa/offline-navigation.ts diff --git a/README.md b/README.md index 4a1a137ee..392723cc0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Try the [live demo](https://demo.healthlog.dev) to see a working install — sig - **AI insights you own.** Daily briefing, health scores, correlations, and a conversational coach grounded in your own measurements and interpreted against cited guideline reference bands — where a value sits and what that means in plain words, never a diagnosis. The coach proposes concrete if-then plans you accept or decline, and suppresses proactive check-ins when there's nothing genuine to say. Bring your own OpenAI or Anthropic key, or point at a local endpoint (Ollama, LM Studio, vLLM) so AI requests stay on your network. - **Clinician-ready output.** A doctor-report PDF rendered on your server, a read-only HL7 FHIR R4 API, and scoped, time-limited share links you revoke after the visit. - **Private by construction.** AES-256-GCM encryption at rest for notes and other free text, documents, questionnaire answers, AI-written text and credentials, with zero-downtime key rotation. Measurement values, lab values, medication names and mood scores are stored unencrypted in the database, so the database volume and its backups need the same protection as the host. Passkey login, server-side sessions, strict CSP, and no third-party tracking anywhere. -- **Built to be lived in.** Installable PWA with offline support, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end. +- **Built to be lived in.** Installable PWA that keeps the open page readable when the connection drops, medication reminders over APNs, Telegram, ntfy, and Web Push, a sub-second dashboard on years of imported history — in English, German, Spanish, French, Italian, Polish, Korean, Dutch, and Brazilian Portuguese end to end. The full feature tour, integration guides, and API reference live at [docs.healthlog.dev](https://docs.healthlog.dev). diff --git a/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts index 28b34c16a..b71d67ac0 100644 --- a/e2e/pwa-offline.spec.ts +++ b/e2e/pwa-offline.spec.ts @@ -12,12 +12,14 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; * * 1. A page that is open when the connection drops stays the app and says * so (the offline strip). - * 2. Opening a page without a connection gets the worker's offline page: - * the word "Offline" and a working retry control, never the browser's - * error. (Signed-in pages are never cached, by design: the proxy pins - * `private, no-store` on them because some carry the record in the - * HTML. So this is what an offline launch of the installed app shows.) - * 3. When the connection returns, that page reloads into the app by + * 2. A tap on a link to another page keeps the open page and repeats the + * hint, instead of trading the readable page for the offline one. + * 3. Opening a page without a connection (a typed URL, a cold start) gets + * the worker's offline page: the word "Offline" and a working retry + * control, never the browser's error. Signed-in pages are never cached: + * the proxy pins `private, no-store` on them because some carry the + * record in the HTML. + * 4. When the connection returns, that page reloads into the app by * itself. * * The account is the populated mobile-sweep account; nothing here writes. @@ -25,7 +27,7 @@ import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; test.describe("offline, with the service worker", () => { test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); - test("an open page says it is offline, a new one opens the offline page, and both recover @critical", async ({ + test("an open page stays readable offline, a new one opens the offline page, and both recover @critical", async ({ page, context, }) => { @@ -42,6 +44,16 @@ test.describe("offline, with the service worker", () => { await context.setOffline(true); await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); + // A tap on the dashboard link stays on the open page. + await page.locator('a[href="/"]').first().click(); + await expect( + page + .locator("[data-sonner-toast]") + .filter({ hasText: "other pages open once you're back online" }), + ).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/medications"); + await expect(page.locator("#main-content")).toBeVisible(); + const response = await page.goto("/timeline"); expect(response?.status()).toBe(503); await expect(page.getByText("Offline", { exact: true })).toBeVisible(); diff --git a/messages/de.json b/messages/de.json index 4dcc47c51..d48421401 100644 --- a/messages/de.json +++ b/messages/de.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Keine Verbindung: Änderungen können erst gespeichert werden, wenn du wieder online bist.", "saveFailed": "Speichern fehlgeschlagen, du bist offline. Bitte versuche es erneut, sobald du wieder online bist.", - "showingCached": "Zeigt deine zuletzt synchronisierten Daten." + "stayOnPage": "Diese Seite bleibt lesbar, andere Seiten öffnen sich, sobald du wieder online bist." }, "demoBanner": { "message": "Demo-Modus: Änderungen werden nicht gespeichert." diff --git a/messages/en.json b/messages/en.json index 6a5c05ac9..791c9073a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "No connection: changes can't be saved until you're back online.", "saveFailed": "Couldn't save, you're offline. Please try again once you're back online.", - "showingCached": "Showing your last synced data." + "stayOnPage": "This page stays readable; other pages open once you're back online." }, "demoBanner": { "message": "Demo mode: changes are not saved." diff --git a/messages/es.json b/messages/es.json index a2b612bad..1efa45ab6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Sin conexión: los cambios no se pueden guardar hasta que vuelvas a estar en línea.", "saveFailed": "No se pudo guardar porque estás sin conexión. Inténtalo de nuevo cuando vuelvas a estar en línea.", - "showingCached": "Mostrando tus últimos datos sincronizados." + "stayOnPage": "Esta página se puede seguir leyendo; las demás se abrirán cuando vuelvas a estar en línea." }, "demoBanner": { "message": "Modo demo: los cambios no se guardan." diff --git a/messages/fr.json b/messages/fr.json index af97df036..bfc94b278 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Pas de connexion : les modifications ne peuvent pas être enregistrées tant que vous n'êtes pas de nouveau en ligne.", "saveFailed": "Enregistrement impossible : vous êtes hors ligne. Réessayez une fois de nouveau en ligne.", - "showingCached": "Affichage de vos dernières données synchronisées." + "stayOnPage": "Cette page reste lisible ; les autres s'ouvriront dès que vous serez de nouveau en ligne." }, "demoBanner": { "message": "Mode démo : les modifications ne sont pas enregistrées." diff --git a/messages/it.json b/messages/it.json index fed51449c..97adcd5d1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Nessuna connessione: le modifiche non possono essere salvate finché non torni online.", "saveFailed": "Impossibile salvare: sei offline. Riprova quando torni online.", - "showingCached": "Vengono mostrati i tuoi ultimi dati sincronizzati." + "stayOnPage": "Questa pagina resta leggibile; le altre si aprono quando torni online." }, "demoBanner": { "message": "Modalità demo: le modifiche non vengono salvate." diff --git a/messages/ko.json b/messages/ko.json index 77eee65e5..8cce4be68 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "연결이 없어요. 온라인으로 돌아올 때까지 저장할 수 없어요.", "saveFailed": "저장하지 못했어요. 오프라인 상태예요. 온라인으로 돌아온 뒤 다시 시도해 주세요.", - "showingCached": "마지막으로 동기화한 데이터를 보여주고 있어요." + "stayOnPage": "이 페이지는 계속 볼 수 있어요. 다른 페이지는 온라인으로 돌아오면 열려요." }, "demoBanner": { "message": "데모 모드: 변경 내용은 저장되지 않아요." diff --git a/messages/nl.json b/messages/nl.json index 1df8e98bf..88cb3077c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Geen verbinding: wijzigingen kunnen pas worden opgeslagen als je weer online bent.", "saveFailed": "Opslaan is mislukt, je bent offline. Probeer het opnieuw zodra je weer online bent.", - "showingCached": "Je laatst gesynchroniseerde gegevens worden getoond." + "stayOnPage": "Deze pagina blijft leesbaar; andere pagina's openen zodra je weer online bent." }, "demoBanner": { "message": "Demomodus: wijzigingen worden niet opgeslagen." diff --git a/messages/pl.json b/messages/pl.json index fa9457d77..c06b9ff77 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Brak połączenia: zmian nie można zapisać, dopóki nie wrócisz online.", "saveFailed": "Nie udało się zapisać, jesteś offline. Spróbuj ponownie, gdy wrócisz online.", - "showingCached": "Wyświetlane są ostatnio zsynchronizowane dane." + "stayOnPage": "Ta strona pozostaje czytelna, inne otworzą się, gdy wrócisz online." }, "demoBanner": { "message": "Tryb demo: zmiany nie są zapisywane." diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 6abf9a204..3591c59cb 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Sem conexão: as alterações não podem ser salvas até você voltar a ficar online.", "saveFailed": "Não foi possível salvar, você está offline. Tente novamente quando voltar a ficar online.", - "showingCached": "Mostrando seus últimos dados sincronizados." + "stayOnPage": "Esta página continua legível; as outras abrem quando você voltar a ficar online." }, "demoBanner": { "message": "Modo demonstração: as alterações não são salvas." diff --git a/messages/ru.json b/messages/ru.json index 159cf4fab..16d386b54 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -452,7 +452,7 @@ "offlineBanner": { "message": "Нет подключения: изменения нельзя сохранить, пока вы снова не в сети.", "saveFailed": "Не удалось сохранить, вы не в сети. Попробуйте снова, когда подключение вернётся.", - "showingCached": "Показаны последние синхронизированные данные." + "stayOnPage": "Эту страницу можно читать дальше, другие откроются, когда вы снова будете в сети." }, "demoBanner": { "message": "Деморежим: изменения не сохраняются." diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 4e39c2009..e480b868d 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2890, - "total": 9211, + "count": 2888, + "total": 9205, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1092,7 +1092,6 @@ "components/layout/__tests__/capture-picker-discard.test.tsx": 1, "components/layout/__tests__/capture-picker.test.tsx": 1, "components/layout/__tests__/nav-model.test.ts": 7, - "components/layout/__tests__/offline-banner.test.tsx": 3, "components/layout/__tests__/shared-record-chrome.test.tsx": 1, "components/layout/__tests__/sidebar-nav.test.tsx": 3, "components/layout/account-switcher-menu.tsx": 1, @@ -1100,7 +1099,6 @@ "components/layout/bottom-nav.tsx": 8, "components/layout/capture-picker.tsx": 5, "components/layout/nav-model.ts": 17, - "components/layout/offline-banner.tsx": 3, "components/layout/record-session-fence-bridge.tsx": 1, "components/layout/shared-record-banner.tsx": 1, "components/layout/shared-record-grant-loss-bridge.tsx": 1, diff --git a/src/components/layout/__tests__/offline-banner.test.tsx b/src/components/layout/__tests__/offline-banner.test.tsx index 3bb7d0bd6..cf7e225ac 100644 --- a/src/components/layout/__tests__/offline-banner.test.tsx +++ b/src/components/layout/__tests__/offline-banner.test.tsx @@ -1,5 +1,5 @@ /** - * v1.4.43 — `` SSR contract. + * `` SSR contract and copy. * * The banner reads `navigator.onLine` + subscribes to `online`/`offline` * window events inside a `useEffect`. SSR markup is therefore always @@ -13,7 +13,6 @@ import { join } from "node:path"; import { describe, it, expect } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { OfflineBanner } from "../offline-banner"; @@ -22,20 +21,16 @@ const ROOT = join(__dirname, "../../../.."); const SHELL_PATH = join(ROOT, "src/components/layout/auth-shell.tsx"); const BANNER_PATH = join(ROOT, "src/components/layout/offline-banner.tsx"); -function loadMessages(locale: string): { offlineBanner: { message: string } } { +function loadMessages(locale: string): { + offlineBanner: { message: string; stayOnPage: string }; +} { const path = join(ROOT, "messages", `${locale}.json`); return JSON.parse(readFileSync(path, "utf8")); } function render(node: React.ReactNode, locale: "en" | "de" = "en") { - // v1.18.6 — the banner now reads the cached dashboard snapshot to decide - // whether to append the "showing last synced data" hint, so it needs a - // QueryClient in context. - const queryClient = new QueryClient(); return renderToStaticMarkup( - - {node} - , + {node}, ); } @@ -65,6 +60,16 @@ describe("", () => { ); }); + it("says what still works: the open page, not the rest of the app", () => { + // The banner used to promise "your last synced data" although one tap + // on another page ended on the worker's offline page. + const en = loadMessages("en"); + expect(en.offlineBanner.stayOnPage).toBe( + "This page stays readable; other pages open once you're back online.", + ); + expect(en.offlineBanner).not.toHaveProperty("showingCached"); + }); + it("ships the message key in every supported locale", () => { for (const locale of [ "en", @@ -76,9 +81,11 @@ describe("", () => { "ko", "nl", "pt-BR", + "ru", ] as const) { const m = loadMessages(locale); expect(m.offlineBanner.message.length, locale).toBeGreaterThan(0); + expect(m.offlineBanner.stayOnPage.length, locale).toBeGreaterThan(0); } }); @@ -89,12 +96,10 @@ describe("", () => { }); }); -// v1.4.43 — also pin the client-side branch: when -// `isOnline === false` the banner paints with the i18n message, -// the WifiOff icon, and the `role="status"` aria-live region. We -// can't easily fire window events from a node-environment vitest -// suite, but we can read the component source and assert the -// branch carries the expected affordances. +// The client-side branch: when `isOnline === false` the banner paints with +// the i18n message, the WifiOff icon, and the `role="status"` aria-live +// region. Window events cannot fire in this node-environment suite, so the +// branch is read from the source. describe(" rendered branch (source-level)", () => { const source = readFileSync(BANNER_PATH, "utf8"); diff --git a/src/components/layout/offline-banner.tsx b/src/components/layout/offline-banner.tsx index e7190d5d2..9879dfc30 100644 --- a/src/components/layout/offline-banner.tsx +++ b/src/components/layout/offline-banner.tsx @@ -2,37 +2,28 @@ import { useEffect, useState } from "react"; import { WifiOff } from "lucide-react"; -import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { useTranslations } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; +import { isOfflineNavigationClick } from "@/lib/pwa/offline-navigation"; /** - * v1.4.43 — slim banner that surfaces "you're offline" while - * `navigator.onLine` reads false. The PWA service worker already - * serves the read paths from cache (`public/sw.js`), but pre-fix a - * user toggling airplane mode mid-form-fill saw blank tiles and no - * explanation. This banner closes the explanation gap. + * The strip that says the connection is down, and the guard that keeps the + * open page while it is. * - * Mount strategy: rendered once at the top of `` so every - * authenticated route inherits it. The component is mount-time SSR- - * safe — it never paints during the initial server render (the - * `online`/`offline` window events only fire client-side); a `useEffect` - * subscribes to the events once and toggles the visible state from - * there. Honours `prefers-reduced-motion` via the absence of any - * animation. + * Signed-in pages are never cached, so a navigation without a connection ends + * on the service worker's offline page and drops the page that was still + * readable. While the browser is offline, a tap on a link to another page of + * the app stays where it is and repeats the hint in a toast. * - * Copy lives in `messages/*.json` under `offlineBanner.message`; all - * six locales ship today. + * Rendered once at the top of ``. Hidden during SSR and the first + * client render (no hydration mismatch); the effect reads `navigator.onLine` + * after mount. */ export function OfflineBanner() { const { t } = useTranslations(); - const queryClient = useQueryClient(); - // Initialise to `true` so the banner stays hidden during SSR + the - // first client render. The effect below flips it true/false after - // mount based on `navigator.onLine`. (We do NOT read navigator on - // the very first render to keep the SSR markup hydration-stable.) const [isOnline, setIsOnline] = useState(true); + const stayOnPage = t("offlineBanner.stayOnPage"); useEffect(() => { function handleOnline() { @@ -41,17 +32,10 @@ export function OfflineBanner() { function handleOffline() { setIsOnline(false); } - // Subscribe to the live transition events; both handlers route - // through the React setState pathway via the listener callback - // rather than a synchronous in-effect setState so the strict - // `react-hooks/set-state-in-effect` rule stays happy. window.addEventListener("online", handleOnline); window.addEventListener("offline", handleOffline); - // Sync the initial state from `navigator.onLine` — deferred to a - // microtask so React sees this as an external-system bridge - // rather than a render-cascade setState. The legacy in-effect - // setState path tripped the strict lint rule, the queueMicrotask - // shape is the documented escape hatch. + // Deferred so the initial read is an external-system bridge, not a + // synchronous setState in the effect body. queueMicrotask(() => { if (typeof navigator !== "undefined" && navigator.onLine === false) { setIsOnline(false); @@ -63,18 +47,21 @@ export function OfflineBanner() { }; }, []); - if (isOnline) return null; + useEffect(() => { + // Capture phase on `window`: runs before React's handlers on the + // document, so `next/link` sees the prevented default and stays put. + function keepOpenPage(event: MouseEvent) { + if (!isOfflineNavigationClick(event, window.location, navigator.onLine)) { + return; + } + event.preventDefault(); + toast(stayOnPage, { id: "offline-navigation" }); + } + window.addEventListener("click", keepOpenPage, true); + return () => window.removeEventListener("click", keepOpenPage, true); + }, [stayOnPage]); - // v1.18.6 — when an offline install is showing last-synced data (the SW's - // allowlisted read cache + the IndexedDB query persister hydrated the - // dashboard snapshot), say so honestly rather than implying a blank screen. - // v1.21.3 (b) — the snapshot cell is now locale-keyed, so match on the - // `["dashboard","snapshot"]` PREFIX (`getQueriesData`) rather than the - // exact zero-arg slot — any hydrated locale's snapshot proves cached data - // is present. - const hasCachedData = queryClient - .getQueriesData({ queryKey: queryKeys.dashboardSnapshot() }) - .some(([, data]) => data != null); + if (isOnline) return null; return (
); diff --git a/src/lib/pwa/__tests__/offline-navigation.test.ts b/src/lib/pwa/__tests__/offline-navigation.test.ts new file mode 100644 index 000000000..15c984120 --- /dev/null +++ b/src/lib/pwa/__tests__/offline-navigation.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; + +import { isOfflineNavigationClick } from "@/lib/pwa/offline-navigation"; + +const HERE = { + origin: "https://app.example", + pathname: "/medications", + search: "", +}; + +interface AnchorInit { + href: string; + target?: string; + download?: boolean; +} + +/** A click on an element inside an anchor, the way a nav icon is tapped. */ +function clickOn( + anchor: AnchorInit | null, + overrides: Partial[0]> = {}, +) { + const link = anchor && { + href: new URL(anchor.href, HERE.origin).href, + target: anchor.target ?? "", + hasAttribute: (name: string) => name === "download" && !!anchor.download, + }; + return { + defaultPrevented: false, + button: 0, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + target: { + closest: (selector: string) => (selector === "a[href]" ? link : null), + } as unknown as EventTarget, + ...overrides, + }; +} + +describe("isOfflineNavigationClick", () => { + it("holds a tap on a link to another page while offline", () => { + expect(isOfflineNavigationClick(clickOn({ href: "/" }), HERE, false)).toBe( + true, + ); + expect( + isOfflineNavigationClick( + clickOn({ href: "/measurements?add=WEIGHT" }), + HERE, + false, + ), + ).toBe(true); + // Same path, different query: a different page for the router. + expect( + isOfflineNavigationClick( + clickOn({ href: "/medications?tab=history" }), + HERE, + false, + ), + ).toBe(true); + }); + + it("lets every link through while online", () => { + expect(isOfflineNavigationClick(clickOn({ href: "/" }), HERE, true)).toBe( + false, + ); + }); + + it("leaves links within the open page alone", () => { + expect( + isOfflineNavigationClick( + clickOn({ href: "/medications#main-content" }), + HERE, + false, + ), + ).toBe(false); + }); + + it("leaves clicks the browser handles itself alone", () => { + const offline = (click: ReturnType) => + isOfflineNavigationClick(click, HERE, false); + expect(offline(clickOn({ href: "https://elsewhere.example/" }))).toBe( + false, + ); + expect(offline(clickOn({ href: "/", target: "_blank" }))).toBe(false); + expect(offline(clickOn({ href: "/export.csv", download: true }))).toBe( + false, + ); + expect(offline(clickOn({ href: "/" }, { metaKey: true }))).toBe(false); + expect(offline(clickOn({ href: "/" }, { button: 1 }))).toBe(false); + expect(offline(clickOn({ href: "/" }, { defaultPrevented: true }))).toBe( + false, + ); + expect(offline(clickOn(null))).toBe(false); + }); +}); diff --git a/src/lib/pwa/offline-navigation.ts b/src/lib/pwa/offline-navigation.ts new file mode 100644 index 000000000..029105a84 --- /dev/null +++ b/src/lib/pwa/offline-navigation.ts @@ -0,0 +1,46 @@ +type ClickLike = Pick< + MouseEvent, + | "defaultPrevented" + | "button" + | "metaKey" + | "ctrlKey" + | "shiftKey" + | "altKey" + | "target" +>; + +/** + * Whether a click would leave the open page for another one of this app while + * the browser is offline. + * + * Signed-in pages are never cached (the proxy sends them `no-store`), so that + * navigation can only end on the service worker's offline page and throws + * away a page that was still readable. Clicks the browser handles on its own + * (new tab, download, another origin) and links within the same page are left + * alone. + */ +export function isOfflineNavigationClick( + event: ClickLike, + here: Pick, + online: boolean, +): boolean { + if (online || event.defaultPrevented || event.button !== 0) return false; + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return false; + } + const anchor = (event.target as Partial | null)?.closest?.( + "a[href]", + ) as HTMLAnchorElement | null | undefined; + // An SVG `` carries an animated string, not a URL. + if (!anchor || typeof anchor.href !== "string") return false; + if (anchor.target && anchor.target !== "_self") return false; + if (anchor.hasAttribute("download")) return false; + let url: URL; + try { + url = new URL(anchor.href, here.origin); + } catch { + return false; + } + if (url.origin !== here.origin) return false; + return url.pathname !== here.pathname || url.search !== here.search; +} From 433b92a67f1ae5c0bc8e871f87c4fb6b411173d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:36:25 +0100 Subject: [PATCH 03/60] fix(pwa): restore the dashboard snapshot only for its own account On a cold start the IndexedDB snapshot was restored before /api/auth/me had answered, and the owner check was skipped while the account was unknown. On a shared browser the previous account's dashboard could be put into the next account's cache until the refetch landed. The restore now waits until the signed-in account is known and runs again when it changes. A snapshot whose owner is not that account, or that was written before any account was known, is discarded and wiped. Because the restore now lands after the page has mounted, it fills queries that have no data yet instead of skipping every query the cache already knows; data the page holds is still never overwritten. --- .../baselines/version-stamp-comments.json | 4 +- src/components/providers.tsx | 16 +-- .../query-persister-shared-record.test.ts | 17 ++- src/lib/pwa/__tests__/query-persister.test.ts | 102 ++++++++++++++++++ src/lib/pwa/query-persister.ts | 57 +++++----- 5 files changed, 152 insertions(+), 44 deletions(-) diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index e480b868d..c2ab886d7 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2888, - "total": 9205, + "total": 9204, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1277,7 +1277,7 @@ "components/onboarding/welcome-screen.tsx": 1, "components/profile/__tests__/height-entry-surfaces.test.tsx": 1, "components/profile/height-field-control.tsx": 1, - "components/providers.tsx": 3, + "components/providers.tsx": 2, "components/records/about-me-note-manager.tsx": 3, "components/records/ai-profile-inclusion-manager.tsx": 1, "components/records/allergy-form.tsx": 1, diff --git a/src/components/providers.tsx b/src/components/providers.tsx index de8dbec17..09eafb194 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -278,18 +278,18 @@ function QueryVisibilityRefreshBridge() { return null; } -// ── Offline query persistence ──────────────────────── +// ── Dashboard snapshot persistence ─────────────────── // -// v1.18.6 — hydrate the last-synced query cache from IndexedDB before the -// first authenticated paint, then debounce-persist successful reads back. -// Combined with the service worker's allowlisted stale-while-revalidate API -// branch, an installed PWA opened offline renders last-known data instead of -// empty skeletons. Build-version + age gated; cleared on logout. +// Fill the dashboard reads from the IndexedDB snapshot as a warm start, then +// debounce-persist them back. The restore waits for `/api/auth/me`: the +// snapshot belongs to one account, and only a known account can be compared +// with its owner. Build-version + age gated; cleared at every session end. function QueryPersistenceBridge() { const queryClient = useQueryClient(); const transition = useRecordSessionTransition(); + const userId = useAuth().user?.id ?? null; useEffect(() => { - if (transition.phase !== "ready") return; + if (transition.phase !== "ready" || userId === null) return; let stop: (() => void) | undefined; let cancelled = false; void restorePersistedQueryCache( @@ -305,7 +305,7 @@ function QueryPersistenceBridge() { cancelled = true; stop?.(); }; - }, [queryClient, transition.phase]); + }, [queryClient, transition.phase, userId]); return null; } diff --git a/src/lib/pwa/__tests__/query-persister-shared-record.test.ts b/src/lib/pwa/__tests__/query-persister-shared-record.test.ts index 9b7626450..a97e5e05e 100644 --- a/src/lib/pwa/__tests__/query-persister-shared-record.test.ts +++ b/src/lib/pwa/__tests__/query-persister-shared-record.test.ts @@ -133,8 +133,13 @@ afterEach(() => { } }); +/** A client signed in as the caller: the persister binds to that account. */ function appClient(): QueryClient { - return new QueryClient({ defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS }); + const client = new QueryClient({ + defaultOptions: QUERY_CLIENT_DEFAULT_OPTIONS, + }); + client.setQueryData(queryKeys.authMe(), { id: "acct-delegate" }); + return client; } describe("persisting while inside another record", () => { @@ -207,7 +212,13 @@ describe("restoring while inside another record", () => { // hash already makes a snapshot written in one record unreadable in // another; asserting only that would be a check that cannot fail for the // control it names. This one counts what landed in the cache, so removing - // the guard leaves exactly one entry here and turns it red. - expect(reader.getQueryCache().getAll()).toHaveLength(0); + // the guard leaves exactly one entry here and turns it red. The only + // entry is the reader's own `/api/auth/me`. + expect( + reader + .getQueryCache() + .getAll() + .map((q) => q.queryKey[0]), + ).toEqual(["auth"]); }); }); diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index e8451186e..b4da283be 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -193,6 +193,7 @@ describe("persister round-trips through a fake IndexedDB", () => { }; const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 }); const stop = startPersistingQueryCache(source, "v1.18.6"); // Force a flush by advancing past the 1s debounce. The trigger write uses @@ -205,6 +206,7 @@ describe("persister round-trips through a fake IndexedDB", () => { stop(); const matchingBuild = new QueryClient(); + matchingBuild.setQueryData(["auth", "me"], { id: "user-a" }); await restorePersistedQueryCache(matchingBuild, "v1.18.6"); expect(matchingBuild.getQueryData(["dashboard", "snapshot"])).toEqual({ weightKg: 81, @@ -220,6 +222,7 @@ describe("persister round-trips through a fake IndexedDB", () => { expect(restored?.isStale()).toBe(true); const foreignBuild = new QueryClient(); + foreignBuild.setQueryData(["auth", "me"], { id: "user-a" }); await restorePersistedQueryCache(foreignBuild, "v9.9.9"); expect( foreignBuild.getQueryData(["dashboard", "snapshot"]), @@ -278,6 +281,7 @@ describe("persister round-trips through a fake IndexedDB", () => { // Persist a STALE (empty) chart series under an allowlisted family. const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); source.setQueryData(["chart-data", "WEIGHT"], []); const stop = startPersistingQueryCache(source, "v1.18.6"); vi.useFakeTimers(); @@ -289,6 +293,7 @@ describe("persister round-trips through a fake IndexedDB", () => { // The live client already fetched the FRESH (post-mutation) series. const live = new QueryClient(); + live.setQueryData(["auth", "me"], { id: "user-a" }); live.setQueryData(["chart-data", "WEIGHT"], [{ id: "fresh-row" }]); await restorePersistedQueryCache(live, "v1.18.6"); @@ -297,6 +302,15 @@ describe("persister round-trips through a fake IndexedDB", () => { expect(live.getQueryData(["chart-data", "WEIGHT"])).toEqual([ { id: "fresh-row" }, ]); + + // A query the page has started but not answered has no data: filled. + const mounting = new QueryClient(); + mounting.setQueryData(["auth", "me"], { id: "user-a" }); + mounting + .getQueryCache() + .build(mounting, { queryKey: ["chart-data", "WEIGHT"] }); + await restorePersistedQueryCache(mounting, "v1.18.6"); + expect(mounting.getQueryData(["chart-data", "WEIGHT"])).toEqual([]); }); it("discards a snapshot stamped with a different account id", async () => { @@ -370,8 +384,96 @@ describe("persister round-trips through a fake IndexedDB", () => { // restore can't resurrect it either. expect(store.get("react-query")).toBeUndefined(); }); + + it("restores nothing until the live account is known", async () => { + installRoundTripIdb(); + const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); + await persistOnce(source); + + // A cold start: the snapshot is on disk, `/api/auth/me` has not answered. + const booting = new QueryClient(); + await restorePersistedQueryCache(booting, "v1.18.6"); + expect(booting.getQueryData(["dashboard", "snapshot"])).toBeUndefined(); + // Not discarded either: the account may well turn out to be its owner. + expect(roundTripStore.get("react-query")).toBeDefined(); + + booting.setQueryData(["auth", "me"], { id: "user-a" }); + await restorePersistedQueryCache(booting, "v1.18.6"); + expect(booting.getQueryData(["dashboard", "snapshot"])).toEqual({ + weightKg: 81, + }); + }); + + it("discards a snapshot written before any account was known", async () => { + installRoundTripIdb(); + await persistOnce(new QueryClient()); + + const live = new QueryClient(); + live.setQueryData(["auth", "me"], { id: "user-b" }); + await restorePersistedQueryCache(live, "v1.18.6"); + expect(live.getQueryData(["dashboard", "snapshot"])).toBeUndefined(); + expect(roundTripStore.get("react-query")).toBeUndefined(); + }); }); +// ── shared fake IndexedDB for the account cases above ─────────────────────── +const roundTripStore = new Map(); + +function installRoundTripIdb(): void { + roundTripStore.clear(); + const makeReq = (result?: unknown) => { + const req: Record = { result }; + queueMicrotask(() => (req.onsuccess as (() => void) | undefined)?.()); + return req; + }; + const fakeDb = { + close() {}, + transaction() { + return { + objectStore() { + return { + put(value: unknown, key: string) { + roundTripStore.set(key, value); + return makeReq(); + }, + get(key: string) { + return makeReq(roundTripStore.get(key)); + }, + delete(key: string) { + roundTripStore.delete(key); + return makeReq(); + }, + }; + }, + set oncomplete(fn: () => void) { + queueMicrotask(fn); + }, + set onerror(_fn: () => void) {}, + }; + }, + }; + (globalThis as { indexedDB?: unknown }).indexedDB = { + open() { + const req: Record = { result: fakeDb }; + queueMicrotask(() => (req.onsuccess as () => void)?.()); + return req; + }, + }; +} + +/** Write one dashboard snapshot from `source` through the 1 s debounce. */ +async function persistOnce(source: QueryClient): Promise { + source.setQueryData(["dashboard", "snapshot"], { weightKg: 81 }); + const stop = startPersistingQueryCache(source, "v1.18.6"); + vi.useFakeTimers(); + source.setQueryData(["chart-data", "WEIGHT"], [{ v: 81 }]); + vi.advanceTimersByTime(1100); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 0)); + stop(); +} + describe("persister is bounded and surfaces quota failures", () => { const original = (globalThis as { indexedDB?: unknown }).indexedDB; diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index eed2a4b7d..4555e4aa6 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -1,10 +1,10 @@ /** - * Minimal IndexedDB-backed TanStack Query persistence (v1.18.6). + * Minimal IndexedDB-backed TanStack Query persistence. * * No external persister dependency: a tiny IDB key/value store plus a manual - * dehydrate-on-cache-change / hydrate-on-boot bridge. The goal is offline - * READ value — an installed PWA opened offline hydrates the last cached - * dashboard/series instantly instead of painting empty skeletons forever. + * dehydrate-on-cache-change / hydrate-after-sign-in bridge. It is a warm + * start for the dashboard reads, not an offline mode: signed-in pages are + * never cached, so the app cannot boot without a connection. * * Safety: * - Only successful (`status: "success"`) query results are persisted. @@ -18,9 +18,10 @@ * - `clearPersistedQueryCache()` is called on every session END (logout AND * the 401 / session-expiry redirect), so a shared device never leaks one * account's cached data to the next once the session is gone. - * - The payload is stamped with the current user id; a restore whose stored - * marker doesn't match the live account discards + clears the cache rather - * than hydrating a foreign account's data. + * - The payload is stamped with the current user id. A restore runs only + * once `["auth","me"]` names the live account, and a stored owner that is + * not that account discards + clears the cache rather than hydrating a + * foreign account's data. * - Respects the centralised queryKey factory: it reads existing query keys, * it never invents new ones. */ @@ -166,9 +167,9 @@ function currentUserId(queryClient: QueryClient): string | null { } /** - * Restore persisted query data into the client (called once before the first - * paint of the authenticated shell). A build-version or age mismatch discards - * the snapshot rather than hydrating a stale/foreign-schema cache. + * Restore persisted query data into the client once the signed-in account is + * known. A build-version, age or owner mismatch discards the snapshot rather + * than hydrating a stale, foreign-schema or foreign-account cache. */ export async function restorePersistedQueryCache( queryClient: QueryClient, @@ -182,6 +183,9 @@ export async function restorePersistedQueryCache( // saying "viewing her record" is the mis-context failure this feature is // built to prevent, in the one place a full reload cannot clear. if (isReadingSharedRecord()) return; + // Without a live account there is nothing to compare the owner with. + const liveUserId = currentUserId(queryClient); + if (liveUserId === null) return; try { const payload = await idbGet(); if (!payload) return; @@ -193,32 +197,24 @@ export async function restorePersistedQueryCache( await clearPersistedQueryCache(); return; } - // Per-account binding: if the live account is known and differs from the - // snapshot's stored owner, this is a different user on the same browser - // profile — discard + clear rather than hydrate a foreign account's data. - // When the live id is not yet known (auth/me not resolved at restore time) - // we don't gate on it; the session-end wipe is the primary guarantee and - // the build-version + 24 h age gates still apply. - const liveUserId = currentUserId(queryClient); - if (liveUserId !== null && payload.userId !== liveUserId) { + // Per-account binding: a snapshot written by anyone but the live account + // (or before any account was known) is another user's on the same + // browser profile. Discard and clear it. + if (payload.userId !== liveUserId) { await clearPersistedQueryCache(); return; } - // Never clobber a query the live client already holds. `hydrate` calls - // `setData` per dehydrated query unconditionally, so a snapshot restored - // in an effect AFTER the first paint would overwrite a query the page - // already fetched/prefetched — replacing the fresh (post-mutation) list - // with the stale persisted (often empty) copy. That was the read-after- - // write break across dashboard/measurements/medications: the restore won - // the race against the in-flight fetch. Drop every dehydrated query whose - // key the cache already carries so restore only ever FILLS gaps, never - // overwrites; a query already present is by definition fresher than disk. + // Only fill gaps, never overwrite data the live client holds: the + // restore lands after the shell has mounted the page, so a query may + // already carry a fresh (post-mutation) answer, and that is fresher than + // disk. A query the page has started but not answered yet has no data + // and is filled; its own fetch replaces the copy when it lands. const cache = queryClient.getQueryCache(); const filtered: DehydratedState = { ...payload.state, queries: payload.state.queries.filter( - (q) => cache.get(q.queryHash) === undefined, + (q) => cache.get(q.queryHash)?.state.data === undefined, ), }; if (filtered.queries.length === 0 || !canHydrate()) return; @@ -230,9 +226,8 @@ export async function restorePersistedQueryCache( // after a create/update then serves the stale pre-mutation copy. Marking // every restored query invalidated makes it stale-but-shown: the persisted // data still paints instantly, and the first mount triggers a background - // refetch. Online that lands fresh data within ms; offline the refetch - // fails and the hydrated copy stays on screen — the offline value holds. - // Scoped to the keys we just hydrated so we never disturb a live query. + // refetch that lands fresh data within ms. Scoped to the keys just + // hydrated so a live query is never disturbed. for (const dq of filtered.queries) { cache.get(dq.queryHash)?.invalidate(); } From 960f3c487889d130c91519f53fca7764608e410d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:37:14 +0100 Subject: [PATCH 04/60] fix(pwa): keep the static cache across sign-in and sign-out The session wipe deleted every HealthLog cache, the static one too, although it holds only locale catalogs and icons. Each sign-in and sign-out threw away the 190 KB catalog and refetched it. The wipe now deletes the page cache and the API caches older releases left behind, current and legacy versions, and keeps the static cache. Function name and signature are unchanged. --- src/lib/pwa/__tests__/query-persister.test.ts | 6 ++++-- src/lib/pwa/query-persister.ts | 15 +++++++-------- src/lib/pwa/sw-cache-policy.ts | 14 ++++++++++---- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index b4da283be..a68f1edf0 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -98,7 +98,7 @@ describe("session-end CacheStorage cleanup", () => { } }); - it("deletes current and legacy HealthLog static/page/data caches only", async () => { + it("deletes current and legacy HealthLog page and data caches only", async () => { const cacheNames = [ "healthlog-static-v1.34.0", "healthlog-static-v1.15.20", @@ -124,9 +124,11 @@ describe("session-end CacheStorage cleanup", () => { .soft(deleted.sort()) .toEqual( cacheNames - .filter((name) => /^healthlog-(?:static|pages|data)-/.test(name)) + .filter((name) => /^healthlog-(?:pages|data)-/.test(name)) .sort(), ); + // The static cache holds catalogs and icons, nothing of an account. + expect(deleted).not.toContain("healthlog-static-v1.34.0"); expect(deleted).not.toContain("unrelated-app-static-v7"); expect(deleted).not.toContain("workbox-precache-healthlog"); }); diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index 4555e4aa6..eee75a8ed 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -36,10 +36,7 @@ import { import { trackBackgroundTask } from "@/lib/logging/background-tasks"; import { queryKeys } from "@/lib/query-keys"; import { isReadingSharedRecord } from "@/lib/query-keys/record-scope"; -// Session-end cleanup removes every current/legacy HealthLog cache family -// while leaving unrelated CacheStorage namespaces on the same origin -// untouched; the name contract is shared with `public/sw.js`. -import { HEALTHLOG_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy"; +import { ACCOUNT_CACHE_NAME_RE } from "@/lib/pwa/sw-cache-policy"; const DB_NAME = "healthlog-query-cache"; const STORE = "kv"; @@ -335,9 +332,11 @@ export async function clearPersistedQueryCache(): Promise { /** * Wipe every client-side cache that can hold one account's data: the - * IndexedDB query snapshot and the worker's caches, including the API caches - * older releases left behind. The single call for a session END (logout or - * 401 / expiry redirect). Best-effort across the board. + * IndexedDB query snapshot, the worker's page cache and the API caches older + * releases left behind, current and legacy versions alike. The static cache + * (catalogs, icons) holds nothing of an account and stays, so a sign-in does + * not download the locale catalog again. The single call for a session END + * (logout or 401 / expiry redirect) and at sign-in. Best-effort. */ export async function clearOfflineCachesForSessionEnd(): Promise { await clearPersistedQueryCache(); @@ -346,7 +345,7 @@ export async function clearOfflineCachesForSessionEnd(): Promise { const keys = await caches.keys(); await Promise.all( keys - .filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k)) + .filter((k) => ACCOUNT_CACHE_NAME_RE.test(k)) .map((k) => caches.delete(k)), ); } catch { diff --git a/src/lib/pwa/sw-cache-policy.ts b/src/lib/pwa/sw-cache-policy.ts index b379b849d..b4890eea3 100644 --- a/src/lib/pwa/sw-cache-policy.ts +++ b/src/lib/pwa/sw-cache-policy.ts @@ -1,9 +1,15 @@ /** * The worker's own cache names: `healthlog-{static,pages,data}-`. - * Everything the app deletes from CacheStorage (session end, the "new - * version" reload) matches against this and nothing else, so another app's - * caches on the same origin are left alone. `data` names the API cache older - * releases kept; it stays so those caches are still deleted. Same pattern as + * Everything the app deletes from CacheStorage matches one of the two + * patterns below and nothing else, so another app's caches on the same + * origin are left alone. `data` names the API cache older releases kept; it + * stays so those caches are still deleted. Same pattern as * `HEALTHLOG_CACHE_NAME_RE` in `public/sw.js`. */ export const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; + +/** + * The caches that can hold something of one account: cached pages and the + * old API answers. The session wipe deletes these and keeps the static cache. + */ +export const ACCOUNT_CACHE_NAME_RE = /^healthlog-(?:pages|data)-/; From 70295f45cb0fbb556f69b7ee3aaa899ccbc0b2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:27:23 +0100 Subject: [PATCH 05/60] fix(push): bring Web Push back after turning it on again A channel switched off because the last browser's endpoint answered 410 (app removed from the Home Screen, site data cleared) stayed off when push was turned on again: the subscription was stored and the test push arrived, but no reminder ever did. Subscribing now re-enables a disabled channel through reEnableChannel, the same reset the status card's button runs. Turning push off on the last browser deleted the subscription and left the channel on, and each later reminder was booked as a failed send (web_push_no_subscriptions) until the channel gave up after five. The dispatcher now leaves channel state alone when a sender had nobody to send to; APNs without a paired iPhone and email without an address or SMTP had the same problem and are covered by the same rule. --- .../web-push/__tests__/route.test.ts | 93 ++++++++++++++++++- src/app/api/notifications/web-push/route.ts | 11 ++- .../__tests__/dispatcher.test.ts | 41 ++++++++ src/lib/notifications/dispatcher.ts | 65 +++++++------ src/lib/notifications/senders/web-push.ts | 13 ++- 5 files changed, 186 insertions(+), 37 deletions(-) diff --git a/src/app/api/notifications/web-push/__tests__/route.test.ts b/src/app/api/notifications/web-push/__tests__/route.test.ts index 237e76856..c93f702f4 100644 --- a/src/app/api/notifications/web-push/__tests__/route.test.ts +++ b/src/app/api/notifications/web-push/__tests__/route.test.ts @@ -31,10 +31,16 @@ vi.mock("@/lib/db", () => ({ // asserting the validation refusals they were written for. appSettings: { findUnique: vi.fn() }, pushSubscription: { upsert: vi.fn(), deleteMany: vi.fn() }, - notificationChannel: { findFirst: vi.fn(), create: vi.fn() }, + notificationChannel: { + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, }, })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); + vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: vi.fn(() => null), @@ -42,12 +48,45 @@ vi.mock("@/lib/logging/context", () => ({ vi.mock("@/lib/crypto", () => ({ encrypt: (v: string) => `enc(${v})` })); +import type { + NotificationChannel, + PushSubscription, +} from "@/generated/prisma/client"; import { POST, DELETE } from "../route"; import { requireAuth } from "@/lib/api-handler"; import { prisma } from "@/lib/db"; const USER = { id: "user-1", username: "u" }; +const STORED_SUBSCRIPTION: PushSubscription = { + id: "sub-1", + userId: USER.id, + endpoint: "https://fcm.googleapis.com/fcm/send/abcdef-routing-secret", + p256dh: "enc(BPkey)", + auth: "enc(authsecret)", + userAgent: null, + createdAt: new Date(0), +}; + +function channelRow(over: Partial): NotificationChannel { + return { + id: "ch-1", + userId: USER.id, + type: "WEB_PUSH", + enabled: true, + config: "enc({})", + createdAt: new Date(0), + updatedAt: new Date(0), + disabledReason: null, + consecutiveFailures: 0, + lastSuccessAt: null, + lastFailureAt: null, + lastFailureReason: null, + nextRetryAt: null, + ...over, + }; +} + /** A well-formed subscription, for the cases that should succeed. */ const GOOD = { endpoint: "https://fcm.googleapis.com/fcm/send/abcdef-routing-secret", @@ -139,6 +178,58 @@ describe("POST /api/notifications/web-push — the subscribe refusal", () => { }); }); +describe("POST /api/notifications/web-push — the channel it delivers through", () => { + // The channel is switched off when the last browser's endpoint answers 410 + // (the app was removed from the Home Screen, site data was cleared) or after + // five failed sends. Subscribing again must switch it back on: before, the + // subscription was stored, the test push arrived and no reminder ever did. + it("switches a disabled channel back on", async () => { + const disabled = channelRow({ + enabled: false, + disabledReason: "web_push_410_gone", + consecutiveFailures: 1, + }); + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( + STORED_SUBSCRIPTION, + ); + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue(disabled); + vi.mocked(prisma.notificationChannel.update).mockResolvedValue({ + ...disabled, + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + }); + + const res = await (POST as unknown as Handler)(req(GOOD)); + + expect(res.status).toBe(200); + expect(prisma.notificationChannel.update).toHaveBeenCalledWith({ + where: { id: "ch-1" }, + data: expect.objectContaining({ + enabled: true, + disabledReason: null, + consecutiveFailures: 0, + }), + }); + expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); + }); + + it("leaves an enabled channel as it is", async () => { + vi.mocked(prisma.pushSubscription.upsert).mockResolvedValue( + STORED_SUBSCRIPTION, + ); + vi.mocked(prisma.notificationChannel.findFirst).mockResolvedValue( + channelRow({}), + ); + + const res = await (POST as unknown as Handler)(req(GOOD)); + + expect(res.status).toBe(200); + expect(prisma.notificationChannel.update).not.toHaveBeenCalled(); + expect(prisma.notificationChannel.create).not.toHaveBeenCalled(); + }); +}); + describe("DELETE /api/notifications/web-push — the unsubscribe refusal", () => { it("names the offending field rather than answering flat", async () => { const res = await (DELETE as unknown as Handler)( diff --git a/src/app/api/notifications/web-push/route.ts b/src/app/api/notifications/web-push/route.ts index 50b0e0c20..7b25c92f2 100644 --- a/src/app/api/notifications/web-push/route.ts +++ b/src/app/api/notifications/web-push/route.ts @@ -5,6 +5,7 @@ import { isChannelGloballyEnabled } from "@/lib/app-settings"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; import { encrypt } from "@/lib/crypto"; +import { reEnableChannel } from "@/lib/notifications/channel-state"; import { webPushSubscriptionSchema } from "@/lib/validations/notifications"; import { z } from "zod/v4"; @@ -97,7 +98,9 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); - // Ensure a WEB_PUSH notification channel exists for this user + // Ensure an enabled WEB_PUSH channel. A channel switched off because the + // last browser's endpoint died (410) or failed five times stays off until + // something turns it back on, and a new subscription is exactly that. const existingChannel = await prisma.notificationChannel.findFirst({ where: { userId: user.id, type: "WEB_PUSH" }, }); @@ -111,6 +114,12 @@ export const POST = apiHandler(async (request: NextRequest) => { config: encrypt(JSON.stringify({})), }, }); + } else if (!existingChannel.enabled) { + await reEnableChannel({ + id: existingChannel.id, + userId: existingChannel.userId, + type: "WEB_PUSH", + }); } return apiSuccess({ subscribed: true }); diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index 7aa7046e9..e5b80df3c 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -207,6 +207,47 @@ describe("dispatchNotification — soft reject (web-push 429)", () => { }); }); +describe("dispatchNotification — nobody to send to", () => { + // Turning push off on the last browser deletes its subscription and leaves + // the channel on. Counted as failures, the next five reminders switched the + // channel off for good, and subscribing again later did not bring it back. + it.each([ + ["WEB_PUSH", "web_push_no_subscriptions"], + ["WEB_PUSH", "web_push_vapid_not_configured"], + ["APNS", "apns_no_devices"], + ["APNS", "apns_not_configured"], + ] as const)( + "%s answering %s leaves the channel state alone", + async (type, reason) => { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + { + ...makeChannel({ type, consecutiveFailures: 4 }), + createdAt: new Date(0), + updatedAt: new Date(0), + disabledReason: null, + lastSuccessAt: null, + lastFailureAt: null, + lastFailureReason: null, + }, + ]); + const sender = type === "APNS" ? sendViaApnsMock : sendViaWebPushMock; + sender.mockResolvedValueOnce({ ok: false, hardReject: false, reason }); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: "u-1", + title: "t", + message: "m", + }); + + expect(sender).toHaveBeenCalledTimes(1); + expect(outcome.dispatched).toBe(false); + expect(prisma.notificationChannel.update).not.toHaveBeenCalled(); + expect(auditLog).not.toHaveBeenCalled(); + }, + ); +}); + describe("dispatchNotification — bounded APNs fallback", () => { it("continues to the next channel after an APNs timeout", async () => { vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index 153217a17..4b102c7c2 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -43,6 +43,23 @@ import { resolveNotificationDeliveryIdentity, } from "@/lib/notifications/delivery-identity"; +/** + * Sender outcomes that mean there was nobody to send to: no browser + * subscribed, no iPhone paired, no address, or a transport the operator has + * not set up. The sender writes a `skipped` ledger row. The channel has not + * failed, so these leave its failure counter alone; counted as failures, + * turning push off on the last device would switch the channel off for good + * after five reminders. + */ +const NO_RECIPIENT_REASONS: ReadonlySet = new Set([ + "web_push_no_subscriptions", + "web_push_vapid_not_configured", + "apns_no_devices", + "apns_not_configured", + "email_no_recipient", + "email_not_configured", +]); + /** * Dispatch a notification to all enabled channels for a user. * Best-effort: logs errors but never throws. @@ -57,6 +74,8 @@ import { * 6. Call the appropriate sender — which now returns a `SendOutcome` so * the dispatcher can classify hard rejects (410, blocked-by-user) vs * soft errors (5xx, 429, network) and update channel state accordingly. + * A send that had nobody to go to (`NO_RECIPIENT_REASONS`) changes no + * channel state. * * Also handles legacy Telegram config stored on the User model by * auto-migrating it to a NotificationChannel record on first dispatch. @@ -352,9 +371,14 @@ export async function dispatchNotification( continue; } + const ref = { + id: channel.id, + userId: channel.userId, + type: channel.type as ChannelType, + }; try { const prepared = prepareChannelSend( - channel.type as ChannelType, + ref.type, channel.config, delivery.recipientUserId, recipientPayload, @@ -402,39 +426,28 @@ export async function dispatchNotification( } } if (outcome.ok) { - await recordChannelSuccess({ - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }); + await recordChannelSuccess(ref); channelsSucceeded += 1; continue; } - if (outcome.hardReject) { - await recordChannelHardReject( - { - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }, - outcome, + if (outcome.reason && NO_RECIPIENT_REASONS.has(outcome.reason)) { + getEvent()?.addMeta( + `notification_skip_${channel.type.toLowerCase()}_no_recipient`, + outcome.reason, ); + continue; + } + + if (outcome.hardReject) { + await recordChannelHardReject(ref, outcome); getEvent()?.addWarning( `Notification channel ${channel.type} auto-disabled: ${outcome.reason}`, ); continue; } - const result = await recordChannelTransientFailure( - { - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }, - outcome, - now, - ); + const result = await recordChannelTransientFailure(ref, outcome, now); getEvent()?.addWarning( `Notification dispatch failed for channel ${channel.type}: ${outcome.reason}` + (result.autoDisabled ? " (auto-disabled after 5 failures)" : ""), @@ -445,11 +458,7 @@ export async function dispatchNotification( // be no-throw, this is defence-in-depth.) const message = err instanceof Error ? err.message : String(err); await recordChannelTransientFailure( - { - id: channel.id, - userId: channel.userId, - type: channel.type as ChannelType, - }, + ref, { reason: "sender_threw", message }, now, ); diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 22bc9528e..42808fcc4 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -24,9 +24,10 @@ import { isSafeAppPath } from "@/lib/notifications/tap-target"; * permanent reject (410/404) and the channel can no longer succeed. * The 410 subscriptions themselves are deleted from the DB regardless, * so a hard-channel-reject means the user has zero devices left. - * - `hardReject: false` for any 5xx, 429, fetch failure, or - * "no subscriptions at all" (treated as soft so the user can re-pair - * a device without the channel staying auto-disabled forever). + * - `hardReject: false` for any 5xx, 429 or fetch failure. + * - `reason: "web_push_no_subscriptions"` (or `_vapid_not_configured`) when + * there is nothing to send to; the dispatcher leaves the channel state + * alone for those. */ export async function sendViaWebPush( userId: string, @@ -65,10 +66,8 @@ export async function sendViaWebPush( }); if (!subscriptions.length) { - // No devices registered yet — not a hard channel reject. The user - // may simply not have hit "Subscribe" on this browser. Counting - // this as a give-up signal would lock the user out of the channel - // they just configured. Treat it as a soft "no recipient" instead. + // No browser subscribed (never, or push was turned off on the last + // one). Nothing failed; the dispatcher books this as a skip. recordPushAttemptForPayload(payload, userId, { userId, channel: "WEB_PUSH", From b3b4c9684eae11d859a1364314aab5301f213310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:38:41 +0100 Subject: [PATCH 06/60] fix(pwa): keep the daily briefing text out of IndexedDB The dashboard snapshot carries the daily briefing and its recall line, and the persister wrote the snapshot to IndexedDB for up to a day, although its own comment said narrative text never reaches the disk. The persister now nulls both fields before the write. The web dashboard reads the briefing from the Today digest, which is never persisted, so the restored snapshot paints exactly what it did before. --- src/lib/pwa/__tests__/query-persister.test.ts | 28 +++++++++++++ src/lib/pwa/query-persister.ts | 40 ++++++++++++++++--- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index a68f1edf0..2c7b08a12 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -407,6 +407,34 @@ describe("persister round-trips through a fake IndexedDB", () => { }); }); + it("writes the dashboard snapshot without the model-written briefing", async () => { + installRoundTripIdb(); + const source = new QueryClient(); + source.setQueryData(["auth", "me"], { id: "user-a" }); + const briefing = { headline: "Your pressure ran high this week." }; + const briefingMemory = { recall: "Last week: steady.", forward: "Calm." }; + source.setQueryData(["dashboard", "snapshot", "en"], { + weightKg: 81, + briefing, + briefingMemory, + briefingState: "ready", + }); + await persistOnce(source); + + const written = JSON.stringify(roundTripStore.get("react-query")); + expect(written).toContain('"weightKg":81'); + expect(written).not.toContain(briefing.headline); + expect(written).not.toContain(briefingMemory.recall); + // The live cache keeps what the server sent. + expect( + source.getQueryData<{ briefing: unknown }>([ + "dashboard", + "snapshot", + "en", + ])?.briefing, + ).toBe(briefing); + }); + it("discards a snapshot written before any account was known", async () => { installRoundTripIdb(); await persistOnce(new QueryClient()); diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index eee75a8ed..7183831a1 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -12,9 +12,11 @@ * dashboard-offline families off disk. This is the privacy floor: a * denylist would persist every family it didn't name (cycle, mood, illness, * labs, coach, insights, medications, …) in plaintext. Only the dashboard - * snapshot, its resolved widget layout, and the measurement daily-series — - * the data the dashboard needs to paint last-known values offline — are - * eligible. Clinical / narrative families are never written to disk. + * snapshot, its resolved widget layout, and the measurement daily-series + * are eligible. Clinical / narrative families are never written to disk. + * - Model-written text never is either: the snapshot carries the daily + * briefing and its recall line, and both are nulled before the write + * (`withoutModelText`). * - `clearPersistedQueryCache()` is called on every session END (logout AND * the 401 / session-expiry redirect), so a shared device never leaks one * account's cached data to the next once the session is gone. @@ -57,8 +59,7 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB /** * Strict allowlist of the only query families written to disk — the - * low-sensitivity reads the dashboard needs to paint last-known values when - * opened offline. Everything else (coach, insights, illness, labs, mood, + * low-sensitivity reads the dashboard paints first. Everything else (coach, insights, illness, labs, mood, * medications, cycle, auth, admin, tokens, …) stays in memory only. * * - `["dashboard", …]` — the unified first-paint snapshot @@ -80,6 +81,29 @@ const MAX_PERSIST_BYTES = 2 * 1024 * 1024; // 2 MB */ const PERSIST_ALLOWLIST_HEADS = ["dashboard", "chart-data"] as const; +/** Snapshot fields a model may have written: the daily briefing and its recall line. */ +const MODEL_TEXT_FIELDS = ["briefing", "briefingMemory"] as const; + +/** + * The dashboard snapshot with its model-written fields nulled. The web + * dashboard reads the briefing from the Today digest, which is never + * persisted, so the restored snapshot loses nothing it paints. Returns new + * objects; the live cache keeps its copy. + */ +export function withoutModelText( + query: DehydratedState["queries"][number], +): DehydratedState["queries"][number] { + const data = query.state.data; + if (query.queryKey[0] !== "dashboard" || !data || typeof data !== "object") { + return query; + } + const stripped: Record = { ...data }; + for (const field of MODEL_TEXT_FIELDS) { + if (field in stripped) stripped[field] = null; + } + return { ...query, state: { ...query.state, data: stripped } }; +} + export function isPersistableKey(queryKey: readonly unknown[]): boolean { const head = queryKey[0]; if (typeof head !== "string") return false; @@ -253,10 +277,14 @@ export function startPersistingQueryCache( // the flush (rather than filtering keys) also means the last good snapshot // of the caller's own dashboard survives a switch untouched. if (isReadingSharedRecord()) return; - const state = dehydrate(queryClient, { + const dehydrated = dehydrate(queryClient, { shouldDehydrateQuery: (query) => query.state.status === "success" && isPersistableKey(query.queryKey), }); + const state: DehydratedState = { + ...dehydrated, + queries: dehydrated.queries.map(withoutModelText), + }; const payload: PersistedPayload = { buildVersion, savedAt: Date.now(), From 31f296a03e5c769a98e611a042f61c625fc37287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:39:07 +0100 Subject: [PATCH 07/60] fix(push): stop the invisible push sent for every recorded dose Each taken or skipped dose sent a `clear` push to every subscription of the account. The service worker closed the pending reminder and showed nothing. A subscription made with userVisibleOnly may not receive pushes that show no notification: Safari revokes it after a few, the next reminder meets a 410 and the channel is switched off, with nothing in the app saying why. The clear push, its three call sites and the badge count only it fed are removed. A reminder already on screen now stays until it is tapped, dismissed or replaced by the next reminder for the same dose. The sender's badge field went too: nothing else ever set it. The parity snapshots lose the two recorded effects. --- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 8 +- .../__tests__/source-provenance.test.ts | 58 ++-- src/lib/medications/intake-bulk/ingest.ts | 19 -- .../record-intake/record-intake.ts | 26 +- .../update-intake-status.ts | 32 +- .../__tests__/web-push-dose-clear.test.ts | 298 ------------------ .../__tests__/web-push-egress-pin.test.ts | 17 +- src/lib/notifications/senders/web-push.ts | 19 +- src/lib/notifications/web-push-clear.ts | 157 --------- .../medication-intake-bulk.01-mixed.json.snap | 72 +---- ...ation-intake-bulk.02-mixed-again.json.snap | 70 +--- ...ation-intake-bulk.03a-background.json.snap | 22 -- ...ication-intake-bulk.03b-backfill.json.snap | 22 -- ...edication-intake-bulk.05a-native.json.snap | 22 -- ...n-intake-status.11-take-in-place.json.snap | 18 -- ...-intake-status.12-take-slot-move.json.snap | 18 -- ...medication-intake-status.13a-pin.json.snap | 18 -- ...medication-intake-status.14-skip.json.snap | 18 -- ...tatus.17b-record-only-correction.json.snap | 18 -- ...edication-intake-status.18b-site.json.snap | 18 -- ...on-intake-status.20a-writer-take.json.snap | 18 -- ...edication-intake.01-take-in-band.json.snap | 18 -- ...ation-intake.02a-take-standalone.json.snap | 18 -- ...ation-intake.03a-skip-named-slot.json.snap | 18 -- ...ication-intake.03b-skip-slotless.json.snap | 18 -- ...intake.03d-owner-skip-standalone.json.snap | 18 -- .../medication-intake.04a-pin.json.snap | 18 -- ...ication-intake.05-named-backfill.json.snap | 18 -- .../medication-intake.06a-body-key.json.snap | 18 -- ...medication-intake.07a-header-key.json.snap | 18 -- .../medication-intake.08-native.json.snap | 18 -- ...ation-intake.09b-owner-skip-flip.json.snap | 18 -- ...edication-intake.10a-writer-take.json.snap | 18 -- ...ication-intake.11b-site-and-dose.json.snap | 18 -- .../medication-intake.12-one-shot.json.snap | 18 -- .../medication-intake-bulk.parity.test.ts | 30 +- .../medication-intake-status.parity.test.ts | 29 +- .../parity/medication-intake.parity.test.ts | 29 +- 39 files changed, 79 insertions(+), 1233 deletions(-) delete mode 100644 src/lib/notifications/__tests__/web-push-dose-clear.test.ts delete mode 100644 src/lib/notifications/web-push-clear.ts diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index dd83b4bdc..5dea39622 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 344, - "total": 111520, + "total": 111507, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -331,7 +331,7 @@ "lib/multipart/stream-to-disk.ts :: streamMultipartToDisk": 272, "lib/notifications/dispatcher.ts :: dispatchNotification": 378, "lib/notifications/senders/apns.ts :: sendViaApns": 299, - "lib/notifications/senders/web-push.ts :: sendViaWebPush": 268, + "lib/notifications/senders/web-push.ts :: sendViaWebPush": 255, "lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239, "lib/personal-records/pr-direction.ts :: getPRDirection": 208, "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index fc64b4a07..58eb7415a 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2892, - "total": 9220, + "count": 2890, + "total": 9216, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -2590,7 +2590,6 @@ "lib/notifications/__tests__/dispatch-localised-cache.test.ts": 1, "lib/notifications/__tests__/dispatcher.test.ts": 1, "lib/notifications/__tests__/medication-intake-sync.test.ts": 1, - "lib/notifications/__tests__/web-push-dose-clear.test.ts": 1, "lib/notifications/delegated-intake.ts": 2, "lib/notifications/dispatch-localised.ts": 3, "lib/notifications/dispatcher.ts": 3, @@ -2604,10 +2603,9 @@ "lib/notifications/senders/push-attempt-record.ts": 1, "lib/notifications/senders/telegram-keyboards.ts": 1, "lib/notifications/senders/telegram.ts": 4, - "lib/notifications/senders/web-push.ts": 5, + "lib/notifications/senders/web-push.ts": 3, "lib/notifications/senders/webhook.ts": 1, "lib/notifications/types.ts": 21, - "lib/notifications/web-push-clear.ts": 1, "lib/nutrients/__tests__/catalog.test.ts": 1, "lib/nutrients/catalog.ts": 8, "lib/oauth/signed-state.ts": 1, diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 2a24675ad..123500767 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -45,10 +45,15 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), }, auditLog: { create: vi.fn() }, + pushSubscription: { findMany: vi.fn() }, $transaction: vi.fn(), }, })); +vi.mock("@/lib/notifications/vapid-config", () => ({ + getVapidConfig: vi.fn(), +})); + vi.mock("@/lib/idempotency", () => ({ withIdempotency: (fn: (...args: Args) => Promise) => @@ -94,15 +99,6 @@ vi.mock("@/lib/notifications/medication-intake-sync", () => ({ queueMedicationIntakeSync: vi.fn(), })); -// The route dispatches the PWA clear fire-and-forget after a recorded dose; -// both halves are mocked so the test asserts the dispatch, not web-push. -vi.mock("@/lib/notifications/web-push-clear", () => ({ - dispatchMedicationIntakeWebClear: vi.fn().mockResolvedValue(undefined), -})); -vi.mock("@/lib/medications/outstanding-doses", () => ({ - countOutstandingDosesToday: vi.fn(), -})); - vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/bearer", () => ({ resolveBearerToken: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -127,8 +123,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { resolveBearerToken } from "@/lib/auth/bearer"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; +import { getVapidConfig } from "@/lib/notifications/vapid-config"; const USER = { id: "user-1", @@ -210,30 +205,33 @@ describe("POST intake — transport-derived source (iOS #64)", () => { expect(createdSource()).toBe("API"); }); - it("closes the pending dose-due web reminder after a recorded intake", async () => { - // Before this route dispatched the clear, an iOS offline drain (which - // replays free intakes through exactly this endpoint) left the web - // dose-due reminder and the app badge standing after the dose landed. + it("sends no Web Push of its own for a recorded dose", async () => { + // A push that shows no notification gets the subscription revoked by + // Safari after a few. The reminder already on screen stays until it is + // tapped or replaced by the next one for the same slot. vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(countOutstandingDosesToday).mockResolvedValue(3); + vi.mocked(getVapidConfig).mockResolvedValue({ + subject: "mailto:ops@example.com", + publicKey: "pub", + privateKey: "priv", + }); + vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([ + { + id: "s1", + userId: USER.id, + endpoint: "https://push.example.com/x", + p256dh: "a", + auth: "b", + userAgent: null, + createdAt: new Date(0), + }, + ]); const res = await POST(postReq({}), ROUTE_PARAMS); expect(res.status).toBe(201); - // Fire-and-forget: the dispatch runs detached from the response. - await expect - .poll( - () => vi.mocked(dispatchMedicationIntakeWebClear).mock.calls.length, - { timeout: 2_000, interval: 25 }, - ) - .toBe(1); - expect( - vi.mocked(dispatchMedicationIntakeWebClear).mock.calls[0][0], - ).toMatchObject({ - userId: "user-1", - medicationId: "med-1", - badgeCount: 3, - }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(prisma.pushSubscription.findMany).not.toHaveBeenCalled(); }); it("ignores a client-supplied `source` in the body (no mass assignment)", async () => { diff --git a/src/lib/medications/intake-bulk/ingest.ts b/src/lib/medications/intake-bulk/ingest.ts index 30c2d4e27..ac66d2019 100644 --- a/src/lib/medications/intake-bulk/ingest.ts +++ b/src/lib/medications/intake-bulk/ingest.ts @@ -23,8 +23,6 @@ import { refreshDoseDay } from "@/lib/medications/dose-day-refresh"; import { unstableExternalIdMeta } from "@/lib/validations/external-id"; import type { InjectionSiteValue } from "@/lib/validations/medication"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { createBatchState, type BatchState, @@ -198,23 +196,6 @@ async function settleLandedRows(input: BatchInput, state: BatchState) { userId: user.id, originDeviceToken: input.originDeviceToken, }); - - // PWA-only counterpart: close the still-pending dose-due - // reminders for every affected slot over Web Push and refresh the app - // badge once with the post-batch outstanding-dose count. Best-effort, - // fire-and-forget — a clear miss never affects the batch response. - const affectedSlots = Array.from(state.syncSlots.values()); - void (async () => { - const badgeCount = await countOutstandingDosesToday( - user.id, - user.timezone, - ); - await dispatchMedicationIntakeWebClearBulk({ - userId: user.id, - slots: affectedSlots, - badgeCount, - }); - })(); } } diff --git a/src/lib/medications/record-intake/record-intake.ts b/src/lib/medications/record-intake/record-intake.ts index b87562e93..c5a7679d7 100644 --- a/src/lib/medications/record-intake/record-intake.ts +++ b/src/lib/medications/record-intake/record-intake.ts @@ -23,8 +23,6 @@ import { import { reconcileOneShotState } from "@/lib/medications/lifecycle"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { @@ -203,11 +201,7 @@ async function recordAfterWrite( } /** The tails that never affect the response. */ -function startTails( - ctx: RecordIntakeContext, - skipped: boolean, - event: WrittenIntake["event"], -): void { +function startTails(ctx: RecordIntakeContext, skipped: boolean): void { const { user, medicationId: id } = ctx; // (#22) — silent cross-device intake sync: wake the user's OTHER iOS // devices so a running Live Activity / widget reconciles this dose. @@ -217,22 +211,6 @@ function startTails( originDeviceToken: ctx.originDeviceToken, }); - // PWA counterpart of the sync wake above: a dose resolved here (taken or - // skipped) closes the still-pending dose-due Web Push reminder for the - // slot and refreshes the app badge. The other intake routes have carried - // this since v1.18.4; this route is the replay target of the iOS offline - // queue, so a drained dose must clear the web reminder the same way. - // Best-effort, fire-and-forget — the canonical row is already persisted. - void (async () => { - const badgeCount = await countOutstandingDosesToday(user.id, user.timezone); - await dispatchMedicationIntakeWebClear({ - userId: user.id, - medicationId: id, - scheduledFor: event.scheduledFor.toISOString(), - badgeCount, - }); - })(); - // "Somebody else marked your dose". This route has no snooze arm, // so the state is whichever of the two markings the payload carried. The // helper refuses on self, so a person marking their own dose is unaffected. @@ -304,7 +282,7 @@ export async function recordIntake( await settleInventory(ctx, skipped, written); await recordAfterWrite(ctx, skipped, written.event); - startTails(ctx, skipped, written.event); + startTails(ctx, skipped); return { kind: "recorded", event: written.event }; } diff --git a/src/lib/medications/update-intake-status/update-intake-status.ts b/src/lib/medications/update-intake-status/update-intake-status.ts index 4263ae952..0c841df5d 100644 --- a/src/lib/medications/update-intake-status/update-intake-status.ts +++ b/src/lib/medications/update-intake-status/update-intake-status.ts @@ -19,9 +19,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate"; import { refreshDoseSlot } from "@/lib/medications/dose-day-refresh"; import { restoreForIntake } from "@/lib/medications/inventory/consumption"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; -import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-clear"; import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; -import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; import { refuseDelegatedChange, refusesUntrackedIntake, @@ -95,7 +93,7 @@ async function recordAfterUpdate( data: IntakeStatusUpdate, updated: UpdatedRow | null, userTzForHook: string, -): Promise { +): Promise { const { user, existing } = ctx; const { intakeId, status } = data; await auditLog("medications.intake.update", { @@ -135,15 +133,12 @@ async function recordAfterUpdate( tz: userTzForHook, }); } - return movedTo; } /** The tails that never affect the response. */ function startTails( ctx: StatusContext, status: IntakeStatusUpdate["status"], - movedTo: Date | undefined, - userTzForHook: string, ): void { const { user, existing } = ctx; // (#22) — silent cross-device intake sync. Wake the user's OTHER @@ -157,27 +152,6 @@ function startTails( originDeviceToken: ctx.originDeviceToken, }); - // PWA-only equivalent of the Live Activity end: when a dose is - // resolved (taken / skipped), push a `type:"clear"` to the user's Web Push - // subscriptions so the still-pending dose-due reminder for this slot is - // closed by the service worker (matched on the stable slot tag) and the app - // badge re-reflects the outstanding-dose count. A snooze leaves the dose - // outstanding, so it neither clears the reminder nor changes the badge. - if (status === "taken" || status === "skipped") { - void (async () => { - const badgeCount = await countOutstandingDosesToday( - user.id, - userTzForHook, - ); - await dispatchMedicationIntakeWebClear({ - userId: user.id, - medicationId: existing.medicationId, - scheduledFor: (movedTo ?? existing.scheduledFor).toISOString(), - badgeCount, - }); - })(); - } - // "Somebody else marked your dose". The helper refuses on self and // on a snooze, resolves both names itself, and never throws. // @@ -243,8 +217,8 @@ export async function updateIntakeStatus( updated = await applySkipOrSnooze(ctx, data); } - const movedTo = await recordAfterUpdate(ctx, data, updated, userTzForHook); - startTails(ctx, status, movedTo, userTzForHook); + await recordAfterUpdate(ctx, data, updated, userTzForHook); + startTails(ctx, status); return { kind: "updated", row: updated }; } diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts deleted file mode 100644 index cbdb11ffe..000000000 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -/** - * v1.18.4 — PWA-only dose lifecycle over Web Push. - * - * Verifies the three pieces a self-hoster without an Apple Developer account - * relies on: - * 1. The dose-due reminder push carries a STABLE per-slot tag + badge count. - * 2. The clear-on-taken push reuses the SAME tag (so the SW closes the - * pending reminder — the equivalent of ending a Live Activity) and - * refreshes the badge. - * 3. A Web Push channel still delivers when APNs is absent. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -// safeFetch's requirePublicHost path drives undici's OWN fetch (version-locked -// with its dispatcher). Delegate it to the global fetch these tests stub so the -// existing interception still applies. -vi.mock("undici", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fetch: (input: unknown, init?: unknown) => - (globalThis.fetch as unknown as (i: unknown, n?: unknown) => unknown)( - input, - init, - ), - }; -}); - -const { generateRequestDetailsMock, findManyMock, deleteManyMock } = vi.hoisted( - () => ({ - generateRequestDetailsMock: vi.fn(), - findManyMock: vi.fn(), - deleteManyMock: vi.fn(), - }), -); - -vi.mock("@/lib/logging/context", () => ({ - getEvent: () => ({ - addWarning: vi.fn(), - addExternalCall: vi.fn(), - setError: vi.fn(), - addMeta: vi.fn(), - }), -})); - -vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ - recordPushAttempt: vi.fn(), - recordPushAttemptForPayload: vi.fn(), -})); - -vi.mock("@/lib/db", () => ({ - prisma: { - pushSubscription: { - findMany: findManyMock, - deleteMany: deleteManyMock, - }, - }, -})); - -vi.mock("@/lib/crypto", () => ({ - decrypt: (v: string) => v, - encrypt: (v: string) => v, -})); - -vi.mock("@/lib/notifications/vapid-config", () => ({ - getVapidConfig: () => - Promise.resolve({ - subject: "mailto:a@b.c", - publicKey: "pub", - privateKey: "priv", - }), -})); - -vi.mock("@/lib/validations/notifications", () => ({ - isPublicUrl: () => true, -})); - -vi.mock("web-push", () => ({ - setVapidDetails: vi.fn(), - // The sender signs + encrypts here and dials through `safeFetch`; the - // payload assertions below read the same second argument as before. - generateRequestDetails: (...args: unknown[]) => { - generateRequestDetailsMock(...args); - return { - endpoint: "https://push.example.com/x", - method: "POST", - headers: {}, - body: null, - }; - }, -})); - -import { sendViaWebPush } from "../senders/web-push"; -import { dispatchMedicationIntakeWebClear } from "../web-push-clear"; -import { medicationDoseTag } from "../dose-tag"; - -const SUB = { - id: "s1", - endpoint: "https://push.example.com/x", - p256dh: "a", - auth: "b", -}; - -beforeEach(() => { - generateRequestDetailsMock.mockReset(); - findManyMock.mockReset(); - deleteManyMock.mockReset(); - deleteManyMock.mockResolvedValue({ count: 0 }); -}); - -afterEach(() => vi.clearAllMocks()); - -describe("medicationDoseTag", () => { - it("is stable across ISO instants that differ only in milliseconds", () => { - const a = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"); - const b = medicationDoseTag("med-1", "2026-06-18T07:00:00Z"); - expect(a).toBe(b); - expect(a).toBe("med:med-1:2026-06-18T07:00:00.000Z"); - }); -}); - -describe("web-push reminder push — stable tag + badge", () => { - it("uses metadata.webPushTag as the notification tag and carries the badge", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - const tag = medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"); - const res = await sendViaWebPush("user-1", { - eventType: "MEDICATION_REMINDER", - userId: "user-1", - title: "Time for your dose", - message: "Ramipril 5mg", - metadata: { webPushTag: tag, badgeCount: 2, url: "/medications/med-1" }, - }); - - expect(res.ok).toBe(true); - const body = JSON.parse( - generateRequestDetailsMock.mock.calls[0][1] as string, - ); - expect(body.tag).toBe(tag); - expect(body.badge).toBe(2); - expect(body.url).toBe("/medications/med-1"); - }); - - it("discreet mode still wins over a stable tag (no event name leak)", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - await sendViaWebPush("user-1", { - eventType: "CYCLE_PERIOD_SOON", - userId: "user-1", - title: "HealthLog reminder", - message: "HealthLog reminder", - discreet: true, - metadata: { webPushTag: "med:should-not-leak" }, - }); - - const body = JSON.parse( - generateRequestDetailsMock.mock.calls[0][1] as string, - ); - expect(body.tag).toBe("REMINDER"); - }); - - it("uses an ordinary landing route without actions for a managed Guardian", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - await sendViaWebPush("guardian-1", { - eventType: "MEDICATION_REMINDER", - userId: "record-1", - recordUserId: "record-1", - recipientUserId: "guardian-1", - title: "Time for your dose", - message: "Ramipril 5mg", - urgent: true, - metadata: { url: "/medications/med-1" }, - }); - - const body = JSON.parse( - generateRequestDetailsMock.mock.calls[0][1] as string, - ); - expect(body.url).toBe("/"); - expect(body.actions).toBeUndefined(); - }); -}); - -describe("web-push tap target", () => { - async function sentBody(payload: Parameters[1]) { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - await sendViaWebPush("user-1", payload); - return JSON.parse(generateRequestDetailsMock.mock.calls[0][1] as string); - } - - it("carries the dispatcher's resolved tap path as data url", async () => { - const body = await sentBody({ - eventType: "MOOD_REMINDER", - userId: "user-1", - title: "Log your mood", - message: "How are you feeling today?", - tap: { path: "/mood?add=1", url: null, label: "Open" }, - }); - expect(body.url).toBe("/mood?add=1"); - }); - - it("refuses a protocol-relative metadata url when no tap was resolved", async () => { - const body = await sentBody({ - eventType: "SYSTEM_ALERT", - userId: "user-1", - title: "t", - message: "m", - metadata: { url: "//evil.example/x" }, - }); - expect(body.url).toBe("/"); - }); -}); - -describe("clear-on-taken push", () => { - it("emits type:clear with the SAME slot tag as the reminder + badge", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - await dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - badgeCount: 1, - }); - - expect(generateRequestDetailsMock).toHaveBeenCalledTimes(1); - const body = JSON.parse( - generateRequestDetailsMock.mock.calls[0][1] as string, - ); - expect(body.type).toBe("clear"); - expect(body.tag).toBe( - medicationDoseTag("med-1", "2026-06-18T07:00:00.000Z"), - ); - expect(body.badge).toBe(1); - }); - - it("a count of 0 clears the badge (badge:0 on the wire)", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 201 }), - ); - - await dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - badgeCount: 0, - }); - - const body = JSON.parse( - generateRequestDetailsMock.mock.calls[0][1] as string, - ); - expect(body.badge).toBe(0); - }); - - it("no subscriptions → no-op, no throw", async () => { - findManyMock.mockResolvedValue([]); - await expect( - dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - }), - ).resolves.toBeUndefined(); - expect(generateRequestDetailsMock).not.toHaveBeenCalled(); - }); - - it("reaps expired (410) subscriptions", async () => { - findManyMock.mockResolvedValue([SUB]); - vi.spyOn(global, "fetch").mockResolvedValue( - new Response(null, { status: 410 }), - ); - - await dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - }); - - expect(deleteManyMock).toHaveBeenCalledWith({ - where: { id: { in: ["s1"] } }, - }); - }); -}); diff --git a/src/lib/notifications/__tests__/web-push-egress-pin.test.ts b/src/lib/notifications/__tests__/web-push-egress-pin.test.ts index 488d4574b..5154ff6b6 100644 --- a/src/lib/notifications/__tests__/web-push-egress-pin.test.ts +++ b/src/lib/notifications/__tests__/web-push-egress-pin.test.ts @@ -11,8 +11,8 @@ * * `web-push`'s `sendNotification` dials with its own internal `https.request`, * which bypasses `safeFetch` entirely and therefore bypasses the pinned - * dispatcher. Both senders now sign and encrypt via `generateRequestDetails` - * — identical VAPID + payload handling — and dial through `safeFetch` with + * dispatcher. The sender signs and encrypts via `generateRequestDetails` + * — identical VAPID + payload handling — and dials through `safeFetch` with * `requirePublicHost: true`. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -76,7 +76,6 @@ vi.mock("web-push", () => ({ })); import { sendViaWebPush } from "../senders/web-push"; -import { dispatchMedicationIntakeWebClear } from "../web-push-clear"; const SUB = { id: "s1", @@ -134,15 +133,3 @@ describe("sendViaWebPush egress", () => { }); }); }); - -describe("dispatchMedicationIntakeWebClear egress", () => { - it("dials through safeFetch with requirePublicHost too", async () => { - await dispatchMedicationIntakeWebClear({ - userId: "user-1", - medicationId: "med-1", - scheduledFor: "2026-06-18T07:00:00.000Z", - }); - expect(safeFetchMock).toHaveBeenCalledTimes(1); - expect(optsOfFirstCall()?.requirePublicHost).toBe(true); - }); -}); diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index 42808fcc4..18f93034b 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -82,24 +82,14 @@ export async function sendViaWebPush( }; } - // v1.18.4 — a dispatcher payload may carry a stable per-slot tag in - // `metadata.webPushTag` (e.g. a medication dose slot, see - // `medicationDoseTag`). The matching `type:"clear"` push reuses the SAME - // tag so the SW can replace/close the pending reminder when the dose is - // logged — the PWA-only equivalent of ending a Live Activity. Discreet - // mode still wins so no event name leaks even when a stable tag exists. + // A payload may carry a stable per-slot tag in `metadata.webPushTag` + // (a medication dose slot, see `medicationDoseTag`), so a repeated + // reminder for the same dose replaces the one on screen instead of + // stacking. Discreet mode still wins so no event name leaks. const stableTag = typeof payload.metadata?.webPushTag === "string" ? payload.metadata.webPushTag : undefined; - // v1.18.4 — server-authoritative app-badge count. The dispatcher puts the - // user's current outstanding-dose count in `metadata.badgeCount`; the SW - // calls `navigator.setAppBadge(count)` (feature-detected, no-op elsewhere). - const badgeCount = - typeof payload.metadata?.badgeCount === "number" && - Number.isFinite(payload.metadata.badgeCount) - ? Math.max(0, Math.trunc(payload.metadata.badgeCount)) - : undefined; const pushPayload = JSON.stringify({ title: plainPushText(payload.title, payload.eventType), @@ -109,7 +99,6 @@ export async function sendViaWebPush( // but the discreet contract still requires no event name on the wire — // mirror the APNs GENERIC_EVENT collapse (QA M-sec3). tag: payload.discreet ? "REMINDER" : (stableTag ?? payload.eventType), - ...(badgeCount !== undefined ? { badge: badgeCount } : {}), // v1.15.20 — opt-in deep link: a dispatcher payload may carry a // same-origin path in `metadata.url` (e.g. the Coach nudge's // `/coach`); everything else keeps the legacy "/" click diff --git a/src/lib/notifications/web-push-clear.ts b/src/lib/notifications/web-push-clear.ts deleted file mode 100644 index 5936cc8ce..000000000 --- a/src/lib/notifications/web-push-clear.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * v1.18.4 — Web Push "clear" for a logged medication dose. - * - * A self-hoster on the installed PWA with no Apple Developer account has no - * Live Activity / Home-Screen widget to reconcile when a dose is logged on - * another tab/device. The native path (`queueMedicationIntakeSync`) ends - * the Live Activity over APNs; this module is the PWA-only equivalent: it - * pushes a `{ type: "clear", tag }` payload to the user's Web Push - * subscriptions so the service worker closes the still-pending dose-due - * reminder for that exact slot (same stable tag the reminder used) and - * refreshes the app badge. - * - * Targeted + best-effort: it talks straight to the user's PushSubscription - * rows (not the full dispatcher cascade) because a clear has no Telegram / - * ntfy / email analogue — those channels can't retract a sent message. Every - * failure is swallowed + annotated; a clear miss never fails the intake - * mutation that triggered it. Expired (410/404) subscriptions are reaped, the - * same as the alert sender. - */ -import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; -import { getVapidConfig } from "@/lib/notifications/vapid-config"; -import { getEvent } from "@/lib/logging/context"; -import { isPublicUrl } from "@/lib/validations/notifications"; -import { safeFetch } from "@/lib/safe-fetch"; -import { medicationDoseTag } from "@/lib/notifications/dose-tag"; - -export interface WebClearInput { - userId: string; - medicationId: string; - /** ISO-8601 scheduled-for instant of the affected dose slot. */ - scheduledFor: string; - /** - * Server-authoritative count of the user's still-outstanding doses after - * this mutation. The SW reflects it via `navigator.setAppBadge`. Omit to - * leave the badge untouched. - */ - badgeCount?: number; -} - -/** - * Send a `type:"clear"` web push that closes the pending reminder for one - * dose slot and updates the app badge. Best-effort, fire-and-forget safe. - */ -export async function dispatchMedicationIntakeWebClear( - input: WebClearInput, -): Promise { - try { - const config = await getVapidConfig(); - if (!config) return; // VAPID not configured — nothing to clear. - - const subscriptions = await prisma.pushSubscription.findMany({ - where: { userId: input.userId }, - }); - if (!subscriptions.length) return; - - const webpush = await import("web-push"); - webpush.setVapidDetails( - config.subject, - config.publicKey, - config.privateKey, - ); - - const badgeCount = - typeof input.badgeCount === "number" && Number.isFinite(input.badgeCount) - ? Math.max(0, Math.trunc(input.badgeCount)) - : undefined; - - const clearPayload = JSON.stringify({ - type: "clear", - tag: medicationDoseTag(input.medicationId, input.scheduledFor), - ...(badgeCount !== undefined ? { badge: badgeCount } : {}), - }); - - const expiredIds: string[] = []; - for (const sub of subscriptions) { - try { - if (!isPublicUrl(sub.endpoint)) { - expiredIds.push(sub.id); - continue; - } - const p256dh = decrypt(sub.p256dh); - const auth = decrypt(sub.auth); - // Same transport swap as the main sender: sign + encrypt via - // `generateRequestDetails`, then dial through `safeFetch` with the - // pinned dispatcher so a DNS rebind between the literal - // `isPublicUrl` check above and the connect cannot reach a private - // address (issue #217). - const details = webpush.generateRequestDetails( - { endpoint: sub.endpoint, keys: { p256dh, auth } }, - clearPayload, - ) as { - endpoint: string; - method: string; - headers: Record; - body: Buffer | string | null; - }; - const clearRes = await safeFetch( - details.endpoint, - { - method: details.method, - headers: details.headers, - body: (details.body ?? undefined) as BodyInit | undefined, - }, - { requirePublicHost: true }, - ); - if (clearRes.status < 200 || clearRes.status >= 300) { - throw Object.assign( - new Error(`Web Push clear responded ${clearRes.status}`), - { statusCode: clearRes.status }, - ); - } - } catch (err: unknown) { - const status = (err as { statusCode?: number }).statusCode; - if (status === 410 || status === 404) expiredIds.push(sub.id); - // Soft errors on a best-effort clear are ignored — the next reminder - // tick or badge refresh self-corrects. - } - } - - if (expiredIds.length > 0) { - await prisma.pushSubscription - .deleteMany({ where: { id: { in: expiredIds } } }) - .catch(() => { - /* best-effort cleanup */ - }); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - getEvent()?.addWarning(`medication_intake_web_clear_failed: ${message}`); - } -} - -/** - * Bulk entry point — de-duplicates affected slots so one clear fires per - * distinct `(medicationId, scheduledFor)` slot a batch touched. The badge - * count is the same post-mutation outstanding total for every slot, so it is - * passed through unchanged. - */ -export async function dispatchMedicationIntakeWebClearBulk(args: { - userId: string; - slots: Array<{ medicationId: string; scheduledFor: string }>; - badgeCount?: number; -}): Promise { - const seen = new Set(); - for (const slot of args.slots) { - const key = `${slot.medicationId}|${slot.scheduledFor}`; - if (seen.has(key)) continue; - seen.add(key); - await dispatchMedicationIntakeWebClear({ - userId: args.userId, - medicationId: slot.medicationId, - scheduledFor: slot.scheduledFor, - badgeCount: args.badgeCount, - }); - } -} diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap index 6a11c1d74..420396b2c 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.01-mixed.json.snap @@ -167,52 +167,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T20:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-16T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T10:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-14T09:30:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { @@ -253,7 +207,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "As-needed pen", "dose": "10mg", @@ -291,7 +245,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Record only", "dose": "10mg", @@ -326,7 +280,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Twice daily", "dose": "10mg", @@ -364,7 +318,7 @@ "schedules": [ { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "07:00", "windowEnd": "08:00", "label": null, @@ -384,7 +338,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -424,7 +378,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -450,7 +404,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T11:00:00.000Z", "skipped": false, @@ -518,7 +472,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -539,7 +493,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -586,7 +540,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T10:00:00.000Z", "takenAt": null, "skipped": true, @@ -607,7 +561,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -628,7 +582,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-16T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -652,7 +606,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap index d75efdf72..d06212ac5 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.02-mixed-again.json.snap @@ -163,50 +163,12 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T20:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-16T08:00:00.000Z" - }, - { - "medicationId": "", - "scheduledFor": "2026-10-15T10:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { "medications": [ { - "id": "", + "id": "", "userId": "", "name": "Apple mirror", "dose": "10mg", @@ -279,7 +241,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Record only", "dose": "10mg", @@ -314,7 +276,7 @@ "updatedAt": "" }, { - "id": "", + "id": "", "userId": "", "name": "Twice daily", "dose": "10mg", @@ -352,7 +314,7 @@ "schedules": [ { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "07:00", "windowEnd": "08:00", "label": null, @@ -372,7 +334,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "08:00", "windowEnd": "10:00", "label": null, @@ -392,7 +354,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "09:00", "windowEnd": "11:00", "label": null, @@ -412,7 +374,7 @@ }, { "id": "", - "medicationId": "", + "medicationId": "", "windowStart": "20:00", "windowEnd": "22:00", "label": null, @@ -438,7 +400,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-13T08:00:00.000Z", "takenAt": "2026-10-13T11:00:00.000Z", "skipped": false, @@ -464,7 +426,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T09:00:00.000Z", "takenAt": "2026-10-14T09:05:00.000Z", "skipped": false, @@ -485,7 +447,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T09:30:00.000Z", "takenAt": "2026-10-14T09:30:00.000Z", "skipped": false, @@ -506,7 +468,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-14T20:00:00.000Z", "takenAt": "2026-10-14T20:05:00.000Z", "skipped": false, @@ -527,7 +489,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T08:00:00.000Z", "takenAt": "2026-10-15T08:30:00.000Z", "skipped": false, @@ -553,7 +515,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T09:00:00.000Z", "takenAt": "2026-10-15T09:20:00.000Z", "skipped": false, @@ -595,7 +557,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-15T20:00:00.000Z", "takenAt": null, "skipped": true, @@ -616,7 +578,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "scheduledFor": "2026-10-16T08:00:00.000Z", "takenAt": null, "skipped": false, @@ -640,7 +602,7 @@ { "id": "", "userId": "", - "medicationId": "", + "medicationId": "", "state": "IN_USE", "containerType": "OTHER", "unitsTotal": "30", diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap index 20fed75d5..5fafea126 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03a-background.json.snap @@ -50,28 +50,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-13T09:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap index 0eabbaeb1..159425a3c 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.03b-backfill.json.snap @@ -49,28 +49,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-12T09:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap index 0f38ac32f..73aca5032 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-bulk.05a-native.json.snap @@ -52,28 +52,6 @@ "originDeviceToken": null } ] - }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, - { - "effect": "dispatchMedicationIntakeWebClearBulk", - "args": [ - { - "userId": "", - "slots": [ - { - "medicationId": "", - "scheduledFor": "2026-10-12T08:00:00.000Z" - } - ], - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap index 99adee5e6..ebe8db65d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.11-take-in-place.json.snap @@ -54,13 +54,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -71,17 +64,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap index ed5baac0a..47e1ccd1a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.12-take-slot-move.json.snap @@ -54,13 +54,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -71,17 +64,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-14T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap index 054ee2099..481661ade 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.13a-pin.json.snap @@ -54,13 +54,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -71,17 +64,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap index 657ce643a..8b1d04e49 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.14-skip.json.snap @@ -48,13 +48,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -65,17 +58,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-12T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap index 934a769af..92693858f 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.17b-record-only-correction.json.snap @@ -48,13 +48,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -65,17 +58,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T07:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap index ced1d3c2c..860b351cb 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.18b-site.json.snap @@ -52,13 +52,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -69,17 +62,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap index 7aa4d03c3..ef64f2835 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake-status.20a-writer-take.json.snap @@ -53,13 +53,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -70,17 +63,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap index 5bfe65d1a..3ad332de5 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.01-take-in-band.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap index 87fd35d61..4edce6678 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.02a-take-standalone.json.snap @@ -53,13 +53,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -70,17 +63,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T11:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap index df5287a0f..473181c7e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03a-skip-named-slot.json.snap @@ -50,13 +50,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -67,17 +60,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap index 121c49ebe..e9df1aaf8 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03b-skip-slotless.json.snap @@ -59,13 +59,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -76,17 +69,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap index 70490b749..bfbfad31e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.03d-owner-skip-standalone.json.snap @@ -50,13 +50,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -67,17 +60,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T11:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap index d2cc36112..c9306045e 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.04a-pin.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-14T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap index 0a3817108..40716b4b7 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.05-named-backfill.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap index 3374e4a7a..741923b7a 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.06a-body-key.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-13T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap index f2645fa53..e26555af0 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.07a-header-key.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-12T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap index decbbe0d3..52155be7f 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.08-native.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-11T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap index 0bdb15dc7..7f5acc55d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.09b-owner-skip-flip.json.snap @@ -50,13 +50,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -67,17 +60,6 @@ "status": "skipped" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-14T20:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap index ca6facc1d..f8cc66ae0 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.10a-writer-take.json.snap @@ -55,13 +55,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -72,17 +65,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-11T08:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap index fdc78e6fd..5363474a9 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.11b-site-and-dose.json.snap @@ -53,13 +53,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -70,17 +63,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap b/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap index 2cb5cf28f..406b3ee7d 100644 --- a/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap +++ b/tests/integration/routes/parity/__snapshots__/medication-intake.12-one-shot.json.snap @@ -52,13 +52,6 @@ } ] }, - { - "effect": "countOutstandingDosesToday", - "args": [ - "", - "UTC" - ] - }, { "effect": "notifyDelegatedIntake", "args": [ @@ -69,17 +62,6 @@ "status": "taken" } ] - }, - { - "effect": "dispatchMedicationIntakeWebClear", - "args": [ - { - "userId": "", - "medicationId": "", - "scheduledFor": "2026-10-15T09:00:00.000Z", - "badgeCount": 2 - } - ] } ], "stored": { diff --git a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts index 4175929d7..bdb82802f 100644 --- a/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-bulk.parity.test.ts @@ -46,9 +46,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => { }; }); -// The tails the route starts without awaiting them (the cross-device push, -// the web-push dose clear with its badge count), recorded instead of run -// (see `effectRecorder`). +// The tail the route starts without awaiting it (the cross-device push), +// recorded instead of run (see `effectRecorder`). vi.mock( "@/lib/notifications/medication-intake-sync", async (importOriginal) => { @@ -65,31 +64,6 @@ vi.mock( }; }, ); -vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => { - const actual = - await importOriginal(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - dispatchMedicationIntakeWebClearBulk: async (...args: unknown[]) => { - effectRecorder.record("dispatchMedicationIntakeWebClearBulk", args); - }, - }; -}); -vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("@/lib/medications/outstanding-doses") - >(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - countOutstandingDosesToday: async (...args: unknown[]) => { - effectRecorder.record("countOutstandingDosesToday", args); - return 2; - }, - }; -}); const prisma = getPrismaClient(); const NOW = new Date("2026-10-15T12:00:00.000Z"); diff --git a/tests/integration/routes/parity/medication-intake-status.parity.test.ts b/tests/integration/routes/parity/medication-intake-status.parity.test.ts index 1c4859108..bb52646c6 100644 --- a/tests/integration/routes/parity/medication-intake-status.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake-status.parity.test.ts @@ -42,8 +42,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => { }); // The tails the route starts without awaiting them (the cross-device push, -// the web-push dose clear with its badge count, the owner's notification of a -// delegated dose), recorded instead of run (see `effectRecorder`). +// the owner's notification of a delegated dose), recorded instead of run (see +// `effectRecorder`). vi.mock( "@/lib/notifications/medication-intake-sync", async (importOriginal) => { @@ -60,31 +60,6 @@ vi.mock( }; }, ); -vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => { - const actual = - await importOriginal(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - dispatchMedicationIntakeWebClear: async (...args: unknown[]) => { - effectRecorder.record("dispatchMedicationIntakeWebClear", args); - }, - }; -}); -vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("@/lib/medications/outstanding-doses") - >(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - countOutstandingDosesToday: async (...args: unknown[]) => { - effectRecorder.record("countOutstandingDosesToday", args); - return 2; - }, - }; -}); vi.mock("@/lib/notifications/delegated-intake", async (importOriginal) => { const actual = await importOriginal< diff --git a/tests/integration/routes/parity/medication-intake.parity.test.ts b/tests/integration/routes/parity/medication-intake.parity.test.ts index e5ac70486..7ce13c706 100644 --- a/tests/integration/routes/parity/medication-intake.parity.test.ts +++ b/tests/integration/routes/parity/medication-intake.parity.test.ts @@ -48,8 +48,8 @@ vi.mock("@/lib/logging/transports", async (importOriginal) => { }); // The tails the route starts without awaiting them (the cross-device push, -// the web-push dose clear with its badge count, the owner's notification of a -// delegated dose), recorded instead of run (see `effectRecorder`). +// the owner's notification of a delegated dose), recorded instead of run (see +// `effectRecorder`). vi.mock( "@/lib/notifications/medication-intake-sync", async (importOriginal) => { @@ -66,31 +66,6 @@ vi.mock( }; }, ); -vi.mock("@/lib/notifications/web-push-clear", async (importOriginal) => { - const actual = - await importOriginal(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - dispatchMedicationIntakeWebClear: async (...args: unknown[]) => { - effectRecorder.record("dispatchMedicationIntakeWebClear", args); - }, - }; -}); -vi.mock("@/lib/medications/outstanding-doses", async (importOriginal) => { - const actual = - await importOriginal< - typeof import("@/lib/medications/outstanding-doses") - >(); - const { effectRecorder } = await import("../../../kit/parity"); - return { - ...actual, - countOutstandingDosesToday: async (...args: unknown[]) => { - effectRecorder.record("countOutstandingDosesToday", args); - return 2; - }, - }; -}); vi.mock("@/lib/notifications/delegated-intake", async (importOriginal) => { const actual = await importOriginal< From 25d087aaa2f3341d5ade0f9507bdb066d7a3105a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:39:08 +0100 Subject: [PATCH 08/60] fix(push): stop handling the clear push in the service worker The worker answered a `type: "clear"` push by closing the dose reminder for that slot and showing nothing. Browsers do not allow a push without a visible notification on a subscription that promised one: Safari removes the subscription after a few, Chromium shows its own "updated in the background" notice. The server no longer sends it, so the branch and its tests go; a reminder stays until it is tapped or the next reminder with the same tag replaces it. --- public/sw.js | 21 --------- .../baselines/version-stamp-comments.json | 5 +- src/__tests__/service-worker.test.ts | 46 ++----------------- 3 files changed, 5 insertions(+), 67 deletions(-) diff --git a/public/sw.js b/public/sw.js index 6dbeb9995..087a1f34e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -530,27 +530,6 @@ self.addEventListener("push", (event) => { payload = { title: "HealthLog", body: event.data.text() }; } - // v1.18.4 — `type:"clear"` is the PWA equivalent of ending an iOS Live - // Activity: the server sends it when a dose is logged so the still-pending - // dose-due reminder for that slot is closed here (matched on its stable - // `tag`), and the app badge re-reflects the outstanding-dose count. No new - // notification is shown. - if (payload && payload.type === "clear") { - event.waitUntil( - (async () => { - const tagToClear = payload.tag; - if (tagToClear) { - const matches = await self.registration.getNotifications({ - tag: tagToClear, - }); - for (const n of matches) n.close(); - } - applyAppBadge(payload.badge); - })(), - ); - return; - } - const { title = "HealthLog", body = "", diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index c2ab886d7..d181dbcd1 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", - "count": 2888, - "total": 9204, + "count": 2887, + "total": 9203, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -55,7 +55,6 @@ "__tests__/recurrence-course-anchor-guard.test.ts": 1, "__tests__/relation-links-backup-completeness.test.ts": 1, "__tests__/resolved-field-consumer-guard.test.ts": 1, - "__tests__/service-worker.test.ts": 1, "__tests__/session-surface-guard.test.ts": 7, "__tests__/settings-legacy-routes.test.ts": 1, "__tests__/shared-record-navigation.test.tsx": 1, diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 301642f89..875a6b3e4 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -158,8 +158,8 @@ function bootServiceWorker(): SwHarness { navPreload.enabled = true; }, }; - // v1.18.4 — push-handler fakes: a notification store the `getNotifications` - // / `showNotification` calls read/write, and a Badging-API spy. + // Push-handler fakes: the notifications `showNotification` shows, and a + // Badging-API spy. const shown: Array<{ title: string; tag?: string; closed: boolean }> = []; const badge = { value: undefined as number | undefined, cleared: false }; const selfObj = { @@ -180,14 +180,6 @@ function bootServiceWorker(): SwHarness { showNotification: async (title: string, opts: { tag?: string }) => { shown.push({ title, tag: opts?.tag, closed: false }); }, - getNotifications: async ({ tag }: { tag?: string }) => - shown - .filter((n) => !n.closed && (tag === undefined || n.tag === tag)) - .map((n) => ({ - close: () => { - n.closed = true; - }, - })), }, navigator: { setAppBadge: (n: number) => { @@ -1080,7 +1072,7 @@ describe("sw.js — trimCache", () => { }); }); -// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ───────────── +// ── push handler ───────────────────────────────────────────────────────────── async function dispatchPush(harness: SwHarness, data: unknown): Promise { let settled: Promise = Promise.resolve(); harness.listeners.get("push")!({ @@ -1117,36 +1109,4 @@ describe("sw.js — push handler", () => { expect(shown[0].tag).toBe(tag); expect(badge.value).toBe(3); }); - - it("a type:clear push closes the matching-tag notification (no new one) and updates the badge", async () => { - const harness = bootServiceWorker(); - const { shown, badge } = swFakes(harness); - const tag = "med:med-1:2026-06-18T07:00:00.000Z"; - - await dispatchPush(harness, { title: "dose", tag, badge: 1 }); - expect(shown.filter((n) => !n.closed)).toHaveLength(1); - - await dispatchPush(harness, { type: "clear", tag, badge: 0 }); - - // The pending reminder for that slot is closed; no new notification shown. - expect(shown).toHaveLength(1); - expect(shown[0].closed).toBe(true); - // badge: 0 clears the app badge. - expect(badge.cleared).toBe(true); - }); - - it("a type:clear push only closes its own slot's tag", async () => { - const harness = bootServiceWorker(); - const { shown } = swFakes(harness); - - await dispatchPush(harness, { title: "a", tag: "med:a:t1" }); - await dispatchPush(harness, { title: "b", tag: "med:b:t2" }); - - await dispatchPush(harness, { type: "clear", tag: "med:a:t1" }); - - const a = shown.find((n) => n.tag === "med:a:t1")!; - const b = shown.find((n) => n.tag === "med:b:t2")!; - expect(a.closed).toBe(true); - expect(b.closed).toBe(false); - }); }); From 61f94478874ea81eb6dfa71ae31c83c38c8f2787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:39:59 +0100 Subject: [PATCH 09/60] fix(pwa): focus the open window on a notification tap instead of reloading it Tapping a reminder called `navigate()` on the first app window even when it already showed the target page: a full document load that dropped the query cache and any open sheet. The rejection of an unawaited `navigate()` on an uncontrolled window went unhandled. A window already on the target is now only focused, and preferred over the first window. Otherwise the window is focused, then navigated; if that fails a new window opens. --- public/sw.js | 22 +++++-- src/__tests__/service-worker.test.ts | 98 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) diff --git a/public/sw.js b/public/sw.js index 087a1f34e..315922846 100644 --- a/public/sw.js +++ b/public/sw.js @@ -580,17 +580,25 @@ self.addEventListener("notificationclick", (event) => { safeUrl = "/"; } + const target = new URL(safeUrl, self.location.origin).href; event.waitUntil( self.clients .matchAll({ type: "window", includeUncontrolled: true }) .then((clientList) => { - for (const client of clientList) { - if (client.url.includes(self.location.origin) && "focus" in client) { - client.navigate(safeUrl); - return client.focus(); - } - } - return self.clients.openWindow(safeUrl); + const windows = clientList.filter( + (c) => c.url.startsWith(self.location.origin + "/") && "focus" in c, + ); + // A window already on the target only needs focus: navigating it + // would reload the app and drop whatever is open there. + const there = windows.find((c) => c.url === target); + if (there) return there.focus(); + const client = windows[0]; + if (!client) return self.clients.openWindow(safeUrl); + // Focus first, while the tap still counts as user activation. + return client + .focus() + .then(() => client.navigate(safeUrl)) + .catch(() => self.clients.openWindow(safeUrl)); }), ); }); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 875a6b3e4..76f6099d5 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -1110,3 +1110,101 @@ describe("sw.js — push handler", () => { expect(badge.value).toBe(3); }); }); + +// ── notification tap ───────────────────────────────────────────────────────── +interface FakeWindow { + url: string; + focused: number; + navigatedTo: string[]; + focus: () => Promise; + navigate: (url: string) => Promise; +} + +function fakeWindow(path: string, navigateFails = false): FakeWindow { + const win: FakeWindow = { + url: `${ORIGIN}${path}`, + focused: 0, + navigatedTo: [], + focus: async () => { + win.focused += 1; + return win; + }, + navigate: async (url: string) => { + if (navigateFails) throw new TypeError("not controlled"); + win.navigatedTo.push(url); + return win; + }, + }; + return win; +} + +async function tapNotification( + harness: SwHarness, + windows: FakeWindow[], + url: string, +): Promise { + const opened: string[] = []; + (harness.context.self as { clients: unknown }).clients = { + claim: async () => {}, + matchAll: async () => windows, + openWindow: async (target: string) => { + opened.push(target); + return null; + }, + }; + let settled: Promise = Promise.resolve(); + harness.listeners.get("notificationclick")!({ + notification: { close: () => {}, data: { url } }, + waitUntil: (p: Promise) => { + settled = p; + }, + }); + await settled; + return opened; +} + +describe("sw.js — notification tap", () => { + it("only focuses a window that is already on the target", async () => { + const harness = bootServiceWorker(); + const win = fakeWindow("/medications"); + + const opened = await tapNotification(harness, [win], "/medications"); + + expect(win.focused).toBe(1); + expect(win.navigatedTo).toEqual([]); + expect(opened).toEqual([]); + }); + + it("prefers the window on the target over the first one", async () => { + const harness = bootServiceWorker(); + const other = fakeWindow("/"); + const there = fakeWindow("/mood?add=1"); + + await tapNotification(harness, [other, there], "/mood?add=1"); + + expect(there.focused).toBe(1); + expect(other.focused).toBe(0); + expect(other.navigatedTo).toEqual([]); + }); + + it("focuses and navigates a window that is elsewhere", async () => { + const harness = bootServiceWorker(); + const win = fakeWindow("/"); + + const opened = await tapNotification(harness, [win], "/medications"); + + expect(win.focused).toBe(1); + expect(win.navigatedTo).toEqual(["/medications"]); + expect(opened).toEqual([]); + }); + + it("opens a window when none is open or navigating fails", async () => { + const harness = bootServiceWorker(); + expect(await tapNotification(harness, [], "/medications")).toEqual([ + "/medications", + ]); + expect( + await tapNotification(harness, [fakeWindow("/", true)], "/medications"), + ).toEqual(["/medications"]); + }); +}); From 09f3280c1c05945a2d232a310097c0e83ccedbad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:40:32 +0100 Subject: [PATCH 10/60] fix(pwa): keep signed-in onboarding pages out of the page cache The proxy treats /onboarding/* as public, so those pages are not sent no-store, and the worker stored the documents a signed-in account saw during setup, against its own rule that only public pages land in the page cache. The worker now skips them by path, like the share view. --- public/sw.js | 31 +++++++++++----------------- src/__tests__/service-worker.test.ts | 16 ++++++++++++++ 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/public/sw.js b/public/sw.js index 315922846..0a4ece131 100644 --- a/public/sw.js +++ b/public/sw.js @@ -195,25 +195,13 @@ async function cacheFirst(request, cacheName) { } /** - * Privacy gate for the navigation page cache. Most app routes are - * client-fetch-only shells with no server-rendered PII (health JSON loads - * over `/api/*`, which is network-only here), so caching their HTML is - * safe. Two carve-outs: - * - * 1. A `Cache-Control: no-store` response opts itself out — the - * principled, future-proof rule for any current/future server RSC - * that renders user data and sets the header. - * 2. `/c/*` (the clinician-share view) renders health values + wellness - * scores straight into the HTML and emits `no-store`; the explicit - * path skip is belt-and-braces so a revoked share can never linger in - * CacheStorage and render back offline. - * - * In practice rule 1 covers every signed-in page: `src/proxy.ts` pins - * `private, no-store` on all of them, because several server-prefetch one - * account's record into the HTML. Only the public pages (sign-in, legal) - * land in this cache, so a navigation to an app page without a connection - * gets the offline page below, never somebody's cached record. A page that - * is already open keeps what it has in memory. + * Which navigation responses may land in the page cache. Signed-in pages + * never do: the proxy sends them `private, no-store` because some carry one + * account's record in the HTML. Two paths are skipped by name as well: the + * clinician share view `/c/*` (no-store too; a revoked share must never + * render back offline) and `/onboarding/*`. What is left are the public + * pages (sign-in, legal), so a navigation to an app page without a + * connection gets the offline page, never somebody's cached record. */ function isCacheableNavigation(request, response) { const cacheControl = response.headers.get("Cache-Control") || ""; @@ -221,6 +209,11 @@ function isCacheableNavigation(request, response) { try { const { pathname } = new URL(request.url); if (pathname === "/c" || pathname.startsWith("/c/")) return false; + // Public to the proxy, so not no-store, but rendered for a signed-in + // account during its setup. + if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) { + return false; + } } catch { // Unparseable URL — fall through; the no-store check already ran. } diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 76f6099d5..5477607dd 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -622,6 +622,22 @@ describe("sw.js — networkFirst privacy gate", () => { expect(await pages.match(`${ORIGIN}/c/hls_abc123`)).toBeUndefined(); }); + it.each(["/onboarding", "/onboarding/who"])( + "does not cache the signed-in onboarding page %s", + async (path) => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => healthlogPage("onboarding step"); + + const res = await dispatchNavigationFetch(harness, path); + expect(await res.clone().text()).toBe("onboarding step"); + await Promise.all(harness.lifetimePromises); + + expect( + await harness.cacheStorage.match(`${ORIGIN}${path}`), + ).toBeUndefined(); + }, + ); + it("still caches an ordinary navigation response", async () => { const harness = bootServiceWorker(); harness.context.fetch = async () => healthlogPage("app shell"); From a824484d4a9ed3866185d938df0af2a2cd77a68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 11 Nov 2026 20:41:05 +0100 Subject: [PATCH 11/60] refactor(pwa): drop the unreachable precached-shell fallback Without a connection a navigation also looked for itself in the static cache, which only ever holds icons and catalogs; `/` is never precached, and the install step deleted a leftover root document only so that this lookup could not find it. Both go; the page cache and the offline page remain. --- public/sw.js | 46 ++++++---------------------- src/__tests__/service-worker.test.ts | 9 ------ 2 files changed, 10 insertions(+), 45 deletions(-) diff --git a/public/sw.js b/public/sw.js index 0a4ece131..8fa86c60d 100644 --- a/public/sw.js +++ b/public/sw.js @@ -74,13 +74,7 @@ self.addEventListener("install", (event) => { event.waitUntil( caches .open(STATIC_CACHE) - .then(async (cache) => { - // An updated worker can reuse the same release-scoped cache when its - // source changes without a version bump. Remove a root document left - // by the previous worker before adding the safe public asset set. - await cache.delete("/"); - await cache.addAll(PRECACHE_URLS); - }) + .then((cache) => cache.addAll(PRECACHE_URLS)) .then(() => self.skipWaiting()), ); }); @@ -383,14 +377,11 @@ async function retireFromForeignOrigin() { } /** - * Network-first: try network (preferring the navigation-preload response - * when the browser already started it), fall back to cache. - * - * The offline fallback only serves a shell cached under the CURRENT - * `CACHE_VERSION` cache names. The previous global `caches.match()` could - * resolve from a stale pre-update cache in the activation gap and serve a - * shell whose `/_next/static/*` chunk graph no longer exists - * (`ChunkLoadError` on the first lazy navigation). + * Network-first for pages: the navigation-preload response when the browser + * already started it, else a fetch. Without a connection, a page from this + * release's page cache (only public pages are there), else the offline page. + * The lookup is scoped to this release's cache name: a page from an older + * release would reference chunks that no longer exist. */ async function networkFirst(event, cacheName) { const { request } = event; @@ -417,28 +408,8 @@ async function networkFirst(event, cacheName) { } return response; } catch { - // Version-scoped fallback: the page cache first, then the precached - // app shell — both names embed CACHE_VERSION, so a stale shell from a - // previous release can never be served here. - const pageCache = await caches.open(cacheName); - const cachedPage = await pageCache.match(request); + const cachedPage = await (await caches.open(cacheName)).match(request); if (cachedPage) return cachedPage; - const staticCache = await caches.open(STATIC_CACHE); - const precached = await staticCache.match(request); - if (precached) return precached; - - // Offline fallback. F-7 (mobile security audit, 2026-05-16): the - // previous fallback rendered hard-coded German body copy, which is - // wrong for any en/* user reaching it offline. The replacement is - // language-neutral — wordmark + the universally understood - // "Offline" token + a generic retry hint expressed as an icon - // (the round-trip arrow), so the page does the right thing in any - // locale without shipping a translation bundle into the worker. - // - // v1.42 — the arrow is a real control now (a link to the same URL, - // 64 px), the page reloads by itself as soon as the connection is - // back, it follows the device's light or dark scheme, and it keeps - // clear of the notch and home indicator of an installed app. return new Response(OFFLINE_PAGE, { status: 503, headers: { "Content-Type": "text/html; charset=utf-8" }, @@ -446,6 +417,9 @@ async function networkFirst(event, cacheName) { } } +// Language-neutral, because the worker ships no catalog: the wordmark, the +// word "Offline" and a 64 px retry link. It reloads itself when the +// connection returns, follows the device scheme and keeps clear of the notch. const OFFLINE_PAGE = 'HealthLog — Offline