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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:31:15 +01:00
1 parent aae029d94e
commit 1d7169b266
8 files changed
+132 -871

No files matched your search

+39 -362
View File
@@ -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<string[]> {
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();
}
});
});
+12 -148
View File
@@ -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 `<path>/` / `<path>?` 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/<locale>?v=<hash>`, `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:
*
@@ -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,
@@ -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,
+63 -110
View File
@@ -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<Response> {
let captured: Promise<Response> | null = null;
harness.listeners.get("fetch")!({
request: new Request(`${ORIGIN}${path}`),
respondWith: (p: Promise<Response>) => {
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<void> {
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 () => {
@@ -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);
});
});
+10 -18
View File
@@ -339,9 +339,9 @@ export async function clearPersistedQueryCache(): Promise<void> {
}
/**
* 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<void> {
@@ -361,23 +361,15 @@ export async function clearOfflineCachesForSessionEnd(): Promise<void> {
}
/**
* 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<void> {
await clearOfflineCachesForSessionEnd();
+2 -75
View File
@@ -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}-<version>`.
* 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;
}