From cd44a8553769633fa69a4634dc4bc68a4d6ae014 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 14:00:11 +0200 Subject: [PATCH] fix(pwa): let the service worker leave an origin HealthLog left The worker is registered with scope / and serves /_next/static/* cache-first. A self-hoster who stopped HealthLog and started another app on the same port kept getting HealthLog's cached chunks until they unregistered the worker by hand. The proxy now marks every page response with a presence-only X-HealthLog-Page header. When a successful navigation arrives without it, the worker stops handling requests for the rest of its life, deletes its own caches and unregisters, and hands the page through untouched. A network failure keeps the offline fallback, and a redirect or error status is never read as another app, so a gateway in front of a stopped HealthLog does not drop the Web Push subscription. Refs #847 --- public/sw.js | 73 +++++++ src/__tests__/proxy-page-marker.test.ts | 72 +++++++ src/__tests__/service-worker.test.ts | 243 +++++++++++++++++++++++- src/proxy.ts | 24 +++ 4 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/proxy-page-marker.test.ts diff --git a/public/sw.js b/public/sw.js index 8c4284924..f2860edd0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -61,6 +61,15 @@ const CURRENT_HEALTHLOG_CACHES = new Set([ PAGE_CACHE, DATA_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 +// `HEALTHLOG_PAGE_MARKER_HEADER` there. +const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page"; +// Set once a navigation proves the origin no longer serves HealthLog. From +// then on the fetch handler declines every request for the rest of this +// worker's life, so the other app's page and its assets come from the network. +let foreignOrigin = false; const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; const MAX_DATA_ENTRIES = 60; @@ -168,6 +177,10 @@ self.addEventListener("activate", (event) => { // ── Fetch: strategy per resource type ──────────────────────────────────────── self.addEventListener("fetch", (event) => { + // The origin serves another app now (see `retireFromForeignOrigin`): no + // `respondWith`, no cache, the browser's own fetch for everything. + if (foreignOrigin) return; + const { request } = event; const url = new URL(request.url); @@ -335,6 +348,61 @@ function isCacheableNavigation(request, response) { return true; } +/** + * Does this navigation response come from something other than HealthLog? + * + * 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. + * + * Only a response that positively looks like another app counts: + * + * - a redirect is opaque to the worker (`opaqueredirect`, status 0, no + * readable headers), and HealthLog redirects every signed-out page, so it + * proves nothing; status 0 fails the `ok` check below; + * - a non-2xx answer proves nothing either: a reverse proxy or auth gateway + * in front of a stopped HealthLog answers 502 or 401 without the marker, + * and unregistering there would also drop the Web Push subscription; + * - a network failure never reaches this check (it throws into the offline + * fallback in `networkFirst`). + * + * What is left is a successful page without the marker HealthLog's proxy puts + * on every page response. + */ +function isForeignNavigationResponse(response) { + if (!response || !response.ok) return false; + return !response.headers.has(HEALTHLOG_PAGE_MARKER_HEADER); +} + +/** + * Leave an origin that no longer serves HealthLog: delete every HealthLog + * cache (only ours, matched by name, never another app's CacheStorage) and + * unregister, so the next load of that origin runs without this worker. The + * `foreignOrigin` flag set by the caller covers the requests the current page + * still makes before it unloads. Each step is best-effort and independent: a + * failed cache delete must not keep the worker registered. + */ +async function retireFromForeignOrigin() { + try { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k)) + .map((k) => caches.delete(k)), + ); + } catch { + // CacheStorage unavailable; unregistering still matters more. + } + try { + await self.registration.unregister(); + } catch { + // Nothing further to do from inside the worker. + } +} + /** * Network-first: try network (preferring the navigation-preload response * when the browser already started it), fall back to cache. @@ -351,6 +419,11 @@ async function networkFirst(event, cacheName) { const response = (event.preloadResponse ? await event.preloadResponse : null) || (await fetch(request)); + if (isForeignNavigationResponse(response)) { + foreignOrigin = true; + event.waitUntil(retireFromForeignOrigin()); + return response; + } if (response.ok && isCacheableNavigation(request, response)) { event.waitUntil( bestEffortCacheWrite( diff --git a/src/__tests__/proxy-page-marker.test.ts b/src/__tests__/proxy-page-marker.test.ts new file mode 100644 index 000000000..9c4c897bc --- /dev/null +++ b/src/__tests__/proxy-page-marker.test.ts @@ -0,0 +1,72 @@ +/** + * The page marker `public/sw.js` uses to recognise that its origin still + * serves HealthLog (#847). A successful navigation without it makes the + * worker delete its caches and unregister, so the marker has to be on every + * page the proxy lets through, signed in or not, and it must never carry a + * version. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { HEALTHLOG_PAGE_MARKER_HEADER, proxy } from "../proxy"; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function request(pathname: string, signedIn = true): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { + headers: signedIn + ? { cookie: "healthlog_session=sess-1", accept: "text/html" } + : { accept: "text/html" }, + }); +} + +describe("proxy.ts page marker for the service worker", () => { + it.each(["/", "/measurements", "/settings/profile", "/c/hls_abc123"])( + "marks the signed-in page %s", + (path) => { + const res = proxy(request(path)); + expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1"); + }, + ); + + it.each(["/auth/login", "/privacy", "/onboarding"])( + "marks the public page %s", + (path) => { + const res = proxy(request(path, false)); + expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1"); + }, + ); + + it.each(["/api/version", "/api/measurements", "/api/auth/me"])( + "leaves the API response %s unmarked", + (path) => { + const res = proxy(request(path)); + expect(res.headers.has(HEALTHLOG_PAGE_MARKER_HEADER)).toBe(false); + }, + ); + + it("carries presence only, never a version or build id", () => { + vi.stubEnv("NEXT_PUBLIC_APP_VERSION", "9.9.9"); + const res = proxy(request("/")); + expect(res.headers.get(HEALTHLOG_PAGE_MARKER_HEADER)).toBe("1"); + }); + + it("uses the header name public/sw.js checks", () => { + const sw = readFileSync(resolve(process.cwd(), "public/sw.js"), "utf8"); + expect(sw).toContain( + `const HEALTHLOG_PAGE_MARKER_HEADER = "${HEALTHLOG_PAGE_MARKER_HEADER}";`, + ); + }); +}); diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index ec749ab7b..82fc300e0 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -14,6 +14,11 @@ * `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). + * + * A HealthLog page response carries the `X-HealthLog-Page` marker the proxy + * sets, so the navigation fixtures below build pages with `healthlogPage()`. + * A successful page without it is another app on the same origin (#847), + * which the "foreign origin" block covers. */ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; @@ -38,6 +43,15 @@ const CURRENT_PAGE_CACHE = `healthlog-pages-${FALLBACK_VERSION}`; const CURRENT_STATIC_CACHE = `healthlog-static-${FALLBACK_VERSION}`; const CURRENT_DATA_CACHE = `healthlog-data-${FALLBACK_VERSION}`; +const PAGE_MARKER = "X-HealthLog-Page"; + +/** A page response as HealthLog's proxy delivers it: marker included. */ +function healthlogPage(body: string, init: ResponseInit = {}): Response { + const headers = new Headers(init.headers); + headers.set(PAGE_MARKER, "1"); + return new Response(body, { ...init, headers }); +} + class FakeCache { map = new Map(); keysCalls = 0; @@ -121,6 +135,7 @@ interface SwHarness { listeners: Map void>; cacheStorage: FakeCacheStorage; navPreload: { enabled: boolean; enable: () => Promise }; + unregisterCalls: { count: number }; lifetimePromises: Promise[]; context: Record; } @@ -129,6 +144,7 @@ function bootServiceWorker(): SwHarness { const listeners = new Map void>(); const cacheStorage = new FakeCacheStorage(); const lifetimePromises: Promise[] = []; + const unregisterCalls = { count: 0 }; const navPreload = { enabled: false, enable: async () => { @@ -147,6 +163,10 @@ function bootServiceWorker(): SwHarness { clients: { claim: async () => {} }, registration: { navigationPreload: navPreload, + unregister: async () => { + unregisterCalls.count += 1; + return true; + }, showNotification: async (title: string, opts: { tag?: string }) => { shown.push({ title, tag: opts?.tag, closed: false }); }, @@ -194,7 +214,14 @@ function bootServiceWorker(): SwHarness { }; vm.createContext(context); vm.runInContext(SW_SOURCE, context); - return { listeners, cacheStorage, navPreload, lifetimePromises, context }; + return { + listeners, + cacheStorage, + navPreload, + unregisterCalls, + lifetimePromises, + context, + }; } async function dispatchActivate(harness: SwHarness): Promise { @@ -389,7 +416,7 @@ describe("sw.js — best-effort cache writes", () => { const harness = bootServiceWorker(); const pageCache = await harness.cacheStorage.open(CURRENT_PAGE_CACHE); pageCache.putError = new Error("CacheStorage quota exceeded"); - const networkResponse = new Response("network shell", { + const networkResponse = healthlogPage("network shell", { headers: { "X-Network-Response": "original" }, }); harness.context.fetch = async () => networkResponse; @@ -413,7 +440,7 @@ describe("sw.js — best-effort cache writes", () => { notifyPutStarted = resolve; }); pageCache.onPut = notifyPutStarted; - const networkResponse = new Response("network shell"); + const networkResponse = healthlogPage("network shell"); harness.context.fetch = async () => networkResponse; const responsePromise = dispatchNavigationFetch(harness, "/measurements"); @@ -572,7 +599,7 @@ describe("sw.js — networkFirst offline fallback", () => { const res = await dispatchNavigationFetch( harness, "/", - Promise.resolve(new Response("preloaded shell")), + Promise.resolve(healthlogPage("preloaded shell")), ); expect(await res.clone().text()).toBe("preloaded shell"); @@ -587,7 +614,7 @@ 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", { + healthlogPage("private shell", { headers: { "Cache-Control": "no-store" }, }); @@ -600,7 +627,7 @@ describe("sw.js — networkFirst privacy gate", () => { 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"); + harness.context.fetch = async () => healthlogPage("share shell"); const res = await dispatchNavigationFetch(harness, "/c/hls_abc123"); expect(await res.clone().text()).toBe("share shell"); @@ -611,7 +638,7 @@ describe("sw.js — networkFirst privacy gate", () => { it("still caches an ordinary navigation response", async () => { const harness = bootServiceWorker(); - harness.context.fetch = async () => new Response("app shell"); + harness.context.fetch = async () => healthlogPage("app shell"); const res = await dispatchNavigationFetch(harness, "/measurements"); expect(await res.clone().text()).toBe("app shell"); @@ -621,6 +648,208 @@ describe("sw.js — networkFirst privacy gate", () => { }); }); +/** + * Dispatch any request and report whether the worker took it. Used where the + * assertion IS whether `respondWith` was called: a declined request goes to + * the network through the browser, untouched by this worker. + */ +function dispatchAnyFetch( + harness: SwHarness, + path: string, + headers: Record = {}, +): { responded: boolean; response: Promise | null } { + let captured: Promise | null = null; + let responded = false; + harness.listeners.get("fetch")!({ + request: new Request(`${ORIGIN}${path}`, { headers }), + respondWith: (p: Promise) => { + responded = true; + captured = p; + }, + waitUntil: (p: Promise) => { + harness.lifetimePromises.push(p); + }, + }); + return { responded, response: captured }; +} + +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"), + ); + 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"); + await legacy.put(`${ORIGIN}/api/version`, new Response("{}")); + // CacheStorage the other app owns on the same origin. + const other = await harness.cacheStorage.open("next-app-runtime"); + await other.put(`${ORIGIN}/x`, new Response("theirs")); +} + +describe("sw.js — foreign origin (#847)", () => { + it("keeps serving and caching while the page carries the HealthLog marker", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + harness.context.fetch = async () => healthlogPage("fresh shell"); + + const res = await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + 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"); + expect(asset.responded).toBe(true); + expect(await (await asset.response!).text()).toBe("healthlog chunk"); + }); + + it("steps aside when a successful page arrives without the marker", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + const foreignPage = new Response("another app", { + headers: { "Content-Type": "text/html" }, + }); + harness.context.fetch = async () => foreignPage; + + const res = await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + // The other app's page is returned untouched and never cached. + expect(res).toBe(foreignPage); + expect(harness.unregisterCalls.count).toBe(1); + // Every HealthLog cache is gone, current and legacy; the other app's is not. + expect([...harness.cacheStorage.stores.keys()]).toEqual([ + "next-app-runtime", + ]); + + // The cached HealthLog chunk would have been served cache-first before. + // Now the worker declines, so the browser fetches the other app's asset. + const asset = dispatchAnyFetch(harness, "/_next/static/chunks/app.js"); + expect(asset.responded).toBe(false); + const nextPage = dispatchAnyFetch(harness, "/about", { + accept: "text/html", + }); + expect(nextPage.responded).toBe(false); + const api = dispatchAnyFetch(harness, "/api/measurements"); + expect(api.responded).toBe(false); + }); + + it("recognises a foreign page delivered through navigation preload", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + let fetchCalls = 0; + harness.context.fetch = async () => { + fetchCalls += 1; + return healthlogPage("unexpected"); + }; + + const res = await dispatchNavigationFetch( + harness, + "/", + Promise.resolve(new Response("another app")), + ); + await Promise.all(harness.lifetimePromises); + + expect(await res.text()).toBe("another app"); + expect(fetchCalls).toBe(0); + expect(harness.unregisterCalls.count).toBe(1); + }); + + it("still unregisters when deleting the caches fails", async () => { + const harness = bootServiceWorker(); + harness.cacheStorage.keys = async () => { + throw new Error("CacheStorage unavailable"); + }; + harness.context.fetch = async () => new Response("another app"); + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(1); + }); + + it("keeps the offline fallback when the network fails (HealthLog is down, not replaced)", async () => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + // Default harness fetch throws: network down. + + const res = await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(await res.text()).toBe("healthlog shell"); + expect(harness.unregisterCalls.count).toBe(0); + expect(harness.cacheStorage.stores.has(CURRENT_STATIC_CACHE)).toBe(true); + }); + + it.each([ + [ + "an error page from HealthLog itself", + healthlogPage("boom", { status: 500 }), + ], + [ + "a gateway error from a proxy in front of a stopped HealthLog", + new Response("Bad Gateway", { status: 502 }), + ], + [ + "a 401 from an auth gateway", + new Response("Unauthorized", { status: 401 }), + ], + ])("does not treat %s as foreign", async (_label, response) => { + const harness = bootServiceWorker(); + await seedHealthlogCaches(harness); + harness.context.fetch = async () => response; + + const res = await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(res).toBe(response); + expect(harness.unregisterCalls.count).toBe(0); + expect( + dispatchAnyFetch(harness, "/_next/static/chunks/app.js").responded, + ).toBe(true); + }); + + it("does not treat an opaque redirect (a signed-out HealthLog page) as foreign", async () => { + const harness = bootServiceWorker(); + const redirect = Response.redirect(`${ORIGIN}/auth/login`, 307); + // A navigation fetched with `redirect: "manual"` surfaces as an opaque + // redirect in a real worker; model the type the worker sees. + Object.defineProperty(redirect, "type", { value: "opaqueredirect" }); + Object.defineProperty(redirect, "headers", { value: new Headers() }); + Object.defineProperty(redirect, "ok", { value: false }); + harness.context.fetch = async () => redirect; + + await dispatchNavigationFetch(harness, "/"); + await Promise.all(harness.lifetimePromises); + + expect(harness.unregisterCalls.count).toBe(0); + }); + + it("leaves the one-shot auth navigation path unchanged", async () => { + const harness = bootServiceWorker(); + let fetchCalls = 0; + harness.context.fetch = async () => { + fetchCalls += 1; + return new Response("network callback"); + }; + + const { responded, response } = dispatchAuthNavigation( + harness, + "/api/auth/oidc/callback?code=abc&state=xyz", + Promise.resolve(new Response("preloaded callback")), + ); + + expect(responded).toBe(true); + expect(await (await response!).text()).toBe("preloaded callback"); + expect(fetchCalls).toBe(0); + // An auth response has no page marker and must not retire the worker. + await Promise.all(harness.lifetimePromises); + expect(harness.unregisterCalls.count).toBe(0); + }); +}); + describe("sw.js — trimCache", () => { it("reads the key list once and deletes only the oldest excess entries", async () => { const harness = bootServiceWorker(); diff --git a/src/proxy.ts b/src/proxy.ts index 23c1e0cf7..7eea0ed35 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -244,6 +244,26 @@ const LEGACY_ADMIN_ANCHORS: Record = { "/admin/section-danger-zone": "/admin/danger-zone", }; +/** + * Presence marker on every HealthLog page response, read by `public/sw.js`. + * + * The service worker is registered with scope `/` and serves `/_next/static/*` + * cache-first. When HealthLog is stopped and a different app starts on the + * same origin (`localhost:3000` on a developer machine is the usual case), the + * worker went on serving HealthLog's cached chunks to that app (#847). It + * needs a way to tell a HealthLog page from someone else's, and nothing + * HealthLog already sends is specific enough: `x-request-id`, the nonce CSP + * and the COOP/COEP set are headers any Next.js app may emit. So the proxy + * adds one of its own. + * + * Presence only, value `1`: no version and no build id, because a version on + * every page is a free fingerprint for anyone scanning for an outdated + * instance and the worker does not need it. Pages only, not `/api/*`: the + * worker checks navigations and nothing else. Keep the name in lockstep with + * `HEALTHLOG_PAGE_MARKER_HEADER` in `public/sw.js`. + */ +export const HEALTHLOG_PAGE_MARKER_HEADER = "X-HealthLog-Page"; + function generateNonce(): string { const bytes = crypto.getRandomValues(new Uint8Array(16)); let binary = ""; @@ -484,6 +504,10 @@ export function proxy(request: NextRequest) { // Request ID for log correlation response.headers.set("x-request-id", requestId); + if (!isApiRoute) { + response.headers.set(HEALTHLOG_PAGE_MARKER_HEADER, "1"); + } + // The document vault's decrypt-and-serve route is framed SAME-ORIGIN by // the document detail preview (an