mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
b4c6c2cc88
commit
2a180fec95
2 files changed
+65
-1
No files matched your search
+27
-1
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user