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] 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; -}