From 2a180fec954cc3258830e4287e6f49abb8bb0493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 10:45:39 +0200 Subject: [PATCH] fix(pwa): keep no-store and clinician-share pages out of the page cache With the service worker now registered for every user, the network-first page cache sees every authenticated navigation. The everyday app routes are client-fetch-only shells with no server-rendered PII, but the `/c/[token]` clinician-share view renders health values straight into the HTML and emits `no-store`. The page cache ignored the header and stored it, so a revoked or expired share could still render back offline. Gate `cache.put` in `networkFirst` on a new `isCacheableNavigation` check: skip any response carrying `Cache-Control: no-store` (the principled rule for any present or future server RSC that renders user data) and skip the `/c/*` path outright as belt-and-braces. Ordinary navigations still cache unchanged. --- public/sw.js | 28 +++++++++++++++++++- src/__tests__/service-worker.test.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/public/sw.js b/public/sw.js index a726a174c..c85334d78 100644 --- a/public/sw.js +++ b/public/sw.js @@ -118,6 +118,32 @@ async function cacheFirst(request, cacheName) { return response; } +/** + * Privacy gate for the navigation page cache. Most app routes are + * client-fetch-only shells with no server-rendered PII (health JSON loads + * over `/api/*`, which is network-only here), so caching their HTML is + * safe. Two carve-outs: + * + * 1. A `Cache-Control: no-store` response opts itself out — the + * principled, future-proof rule for any current/future server RSC + * that renders user data and sets the header. + * 2. `/c/*` (the clinician-share view) renders health values + wellness + * scores straight into the HTML and emits `no-store`; the explicit + * path skip is belt-and-braces so a revoked share can never linger in + * CacheStorage and render back offline. + */ +function isCacheableNavigation(request, response) { + const cacheControl = response.headers.get("Cache-Control") || ""; + if (/no-store/i.test(cacheControl)) return false; + try { + const { pathname } = new URL(request.url); + if (pathname === "/c" || pathname.startsWith("/c/")) return false; + } catch { + // Unparseable URL — fall through; the no-store check already ran. + } + return true; +} + /** * Network-first: try network (preferring the navigation-preload response * when the browser already started it), fall back to cache. @@ -134,7 +160,7 @@ async function networkFirst(event, cacheName) { const response = (event.preloadResponse ? await event.preloadResponse : null) || (await fetch(request)); - if (response.ok) { + if (response.ok && isCacheableNavigation(request, response)) { const cache = await caches.open(cacheName); cache.put(request, response.clone()); trimCache(cacheName, MAX_PAGE_ENTRIES); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 66ba97460..8601c0a41 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -238,6 +238,44 @@ describe("sw.js — networkFirst offline fallback", () => { }); }); +describe("sw.js — networkFirst privacy gate", () => { + it("does not cache a navigation response that carries Cache-Control: no-store", async () => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => + new Response("private shell", { + headers: { "Cache-Control": "no-store" }, + }); + + const res = await dispatchNavigationFetch(harness, "/"); + expect(await res.clone().text()).toBe("private shell"); + + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/`)).toBeUndefined(); + }); + + it("does not cache the /c/ clinician-share view even without no-store", async () => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => new Response("share shell"); + + const res = await dispatchNavigationFetch(harness, "/c/hls_abc123"); + expect(await res.clone().text()).toBe("share shell"); + + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/c/hls_abc123`)).toBeUndefined(); + }); + + it("still caches an ordinary navigation response", async () => { + const harness = bootServiceWorker(); + harness.context.fetch = async () => new Response("app shell"); + + const res = await dispatchNavigationFetch(harness, "/measurements"); + expect(await res.clone().text()).toBe("app shell"); + + const pages = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); + expect(await pages.match(`${ORIGIN}/measurements`)).toBeDefined(); + }); +}); + describe("sw.js — trimCache", () => { it("reads the key list once and deletes only the oldest excess entries", async () => { const harness = bootServiceWorker();