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