From 15f0e499f96ae0fcc5c6c680b4623cd867d378e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 13:56:58 +0200 Subject: [PATCH 01/26] refactor: share the secret-shape matcher from one leaf The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged. --- src/lib/__tests__/secret-shape.test.ts | 25 +++++++++++++++++++++++++ src/lib/idempotency.ts | 17 ++++------------- src/lib/secret-shape.ts | 21 +++++++++++++++++++++ 3 files changed, 50 insertions(+), 13 deletions(-) create mode 100644 src/lib/__tests__/secret-shape.test.ts create mode 100644 src/lib/secret-shape.ts diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts new file mode 100644 index 000000000..2cdbf3d26 --- /dev/null +++ b/src/lib/__tests__/secret-shape.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { looksSecretShaped } from "@/lib/secret-shape"; + +describe("looksSecretShaped", () => { + it.each([ + ["an access token", "token hlk_abc123"], + ["a refresh token", "hlr_xyz789"], + ["a share-link token", "see hls_def456"], + ["an invite token", "hlv_0a1b2c3d"], + ["an elevation token", "hle_0a1b2c3d"], + ["an OpenAI key", "echoed sk-1234567890"], + ["an Anthropic key", "sk-ant-api03-xyz12345"], + ])("matches %s", (_label, text) => { + expect(looksSecretShaped(text)).toBe(true); + }); + + it.each([ + ["a word containing sk-", "task-id must not contain spaces"], + ["another one", "risk-management"], + ["a JSON error body", '{"error":"Bad Request","errorCode":400}'], + ])("does not match %s", (_label, text) => { + expect(looksSecretShaped(text)).toBe(false); + }); +}); diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index c9e2dbeb8..70e2ddcbc 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -28,6 +28,7 @@ import { isP2002 } from "@/lib/prisma-errors"; import { findActiveGrant } from "@/lib/sharing/grants"; import { decrypt, encrypt } from "@/lib/crypto"; import { createHash } from "node:crypto"; +import { looksSecretShaped } from "@/lib/secret-shape"; const TTL_MS = 24 * 60 * 60 * 1000; /** @@ -641,21 +642,11 @@ export function withIdempotency< // Defence-in-depth: never persist a body that carries a freshly-issued // bearer / refresh token or a third-party AI provider key. Auth and // settings routes shouldn't be wrapped in withIdempotency to begin - // with, but if a future caller forgets we refuse to leak. - // `hlk_` = our access tokens - // `hlr_` = our refresh tokens - // `hls_` = clinician share-link tokens (v1.11) - // `hlv_` = registration invite tokens (v1.16 — the admin mint - // response is the one place the raw token appears) - // `sk-…` = OpenAI / Anthropic keys (full token form, not the - // raw substring — a 422 body explaining "task-id…" - // or any other word containing `sk-` would otherwise - // silently break idempotency for benign retries). - const SECRET_PATTERN = - /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + // with, but if a future caller forgets we refuse to leak. The prefixes + // are listed where the matcher lives (`src/lib/secret-shape.ts`). const cloned = response.clone(); const text = await cloned.text(); - if (!SECRET_PATTERN.test(text)) { + if (!looksSecretShaped(text)) { await persistCached(ctx, response, text); } else { // Secret-shaped body — drop the claim so the key isn't left diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts new file mode 100644 index 000000000..7786f7b59 --- /dev/null +++ b/src/lib/secret-shape.ts @@ -0,0 +1,21 @@ +/** + * One definition of "this text carries a credential HealthLog or a common + * AI provider mints", shared by every surface that refuses to store or echo + * such text. + * + * `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link + * tokens, `hlv_` registration invite tokens, `hle_` elevation tokens; + * `sk-…` / `sk-ant-…` OpenAI and Anthropic keys, in the full token form + * only: a bare `sk-` substring ("task-id", "risk-management") is not a key, + * and matching it would refuse benign text. + * + * It is a shape test, not a secret detector. A credential without one of + * these prefixes (a relay's own app token, a password) passes, so a caller + * that knows the secrets in play must also refuse text containing them. + */ +const SECRET_SHAPED = + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hle_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + +export function looksSecretShaped(text: string): boolean { + return SECRET_SHAPED.test(text); +} From afb26251e546e74c3a404e321cec17ba5a4dcf5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 13 Sep 2026 13:57:33 +0200 Subject: [PATCH 02/26] fix(logging): ship to a private Loki and report failed pushes LOKI_ENDPOINT is read only from the server environment, so the push now dials its exact origin through the pinned operator-approved resolver instead of the public-host pin that refused a Loki on the operator's own network. Link-local, metadata and unspecified addresses stay refused. A thrown push or a non-2xx answer used to vanish in an empty catch. It now writes one line to stderr per reason at most every five minutes, naming the Loki origin, the reason and the dropped event count, without credentials. The endpoint accepts both the base URL and the full push URL; the second form used to double the path into a silent 404. Refs #947 --- .env.production.example | 14 +- docker-compose.yml | 9 +- .../logging/__tests__/transports-loki.test.ts | 352 ++++++++++++++++++ src/lib/logging/transports.ts | 172 ++++++++- 4 files changed, 530 insertions(+), 17 deletions(-) create mode 100644 src/lib/logging/__tests__/transports-loki.test.ts diff --git a/.env.production.example b/.env.production.example index 0d5f7a2c5..06ff00f4b 100644 --- a/.env.production.example +++ b/.env.production.example @@ -521,10 +521,16 @@ API_TOKEN_HMAC_KEY="" # LOG_SLOW_THRESHOLD_MS="3000" # LOG_INCLUDE_STACK="false" -# Optional log shipping to a Loki instance. LOKI_ENDPOINT is the push URL and -# is what turns shipping on; the username/password pair is only needed when -# your Loki sits behind basic auth. Leave all three unset and logs stay on -# stdout for the Docker log driver to rotate. +# Optional log shipping to a Loki instance. LOKI_ENDPOINT turns shipping on. +# Either form works: the base URL (http://loki:3100) or the full push URL +# (http://loki:3100/loki/api/v1/push). A Loki on your own network is fine, +# private addresses and internal hostnames included; only link-local and +# cloud-metadata addresses are refused. The username/password pair is only +# needed when your Loki sits behind basic auth. When a push fails (Loki down, +# wrong path, rejected credentials) the app writes one line to stderr naming +# the Loki origin, the reason and how many events were dropped, at most once +# every five minutes per reason; the password never appears in it. Leave all +# three unset and logs stay on stdout for the Docker log driver to rotate. # LOKI_ENDPOINT="" # LOKI_USERNAME="" # LOKI_PASSWORD="" diff --git a/docker-compose.yml b/docker-compose.yml index e55a024af..2ad99006b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -345,10 +345,11 @@ x-healthlog-env: &healthlog_env LOG_SAMPLE_RATE: "${LOG_SAMPLE_RATE:-}" LOG_SLOW_THRESHOLD_MS: "${LOG_SLOW_THRESHOLD_MS:-}" LOG_INCLUDE_STACK: "${LOG_INCLUDE_STACK:-}" - # Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the push - # URL to turn it on; the username/password pair is only needed when your - # Loki is behind basic auth. Leave all three unset and logs stay on stdout - # for the Docker log driver to rotate. + # Optional log shipping to a Loki instance. Set LOKI_ENDPOINT to the base + # URL or the full push URL to turn it on; a Loki on your own network works. + # The username/password pair is only needed when your Loki is behind basic + # auth. Push failures show up as a rate-limited line on stderr. Leave all + # three unset and logs stay on stdout for the Docker log driver to rotate. LOKI_ENDPOINT: "${LOKI_ENDPOINT:-}" LOKI_USERNAME: "${LOKI_USERNAME:-}" LOKI_PASSWORD: "${LOKI_PASSWORD:-}" diff --git a/src/lib/logging/__tests__/transports-loki.test.ts b/src/lib/logging/__tests__/transports-loki.test.ts new file mode 100644 index 000000000..a234d8aa4 --- /dev/null +++ b/src/lib/logging/__tests__/transports-loki.test.ts @@ -0,0 +1,352 @@ +/** + * Loki shipping (#947 follow-up): the endpoint forms an operator types, the + * dial path a private Loki needs, the floor no configuration opens, and the + * stderr notice that replaces the silent catch. + * + * Two layers. The mocked-`safeFetch` block pins what the transport asks for + * and what it says when that fails. The loopback block runs the real + * `safeFetch` against a real HTTP server on 127.0.0.1, which is the case the + * public-host pin used to refuse: an address that is not public at all. + */ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetLoggingConfig } from "../config"; +import type { WideEvent } from "../types"; + +const safeFetchMock = vi.hoisted(() => vi.fn()); +const useRealSafeFetch = vi.hoisted(() => ({ value: false })); + +vi.mock("@/lib/safe-fetch", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + safeFetch: (...args: Parameters) => + useRealSafeFetch.value + ? actual.safeFetch(...args) + : safeFetchMock(...args), + }; +}); + +import { SafeFetchError } from "@/lib/safe-fetch"; +import { + _resetLokiTransportForTests, + emitEvent, + flushLokiBuffer, + LOKI_FAILURE_NOTICE_INTERVAL_MS, + resolveLokiPushTarget, +} from "../transports"; + +const USERNAME = "loki-user"; +const PASSWORD = "s3cret-loki-password"; +const BASIC = Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64"); + +function makeEvent(): WideEvent { + return { + timestamp: new Date().toISOString(), + duration_ms: 1, + request_id: "req-loki", + trace_id: "trace-loki", + level: "info", + kind: "http", + service: "healthlog", + environment: "test", + }; +} + +let stderrLines: string[]; +let stderrSpy: ReturnType; +let stdoutSpy: ReturnType; + +function configure(endpoint: string, withAuth = true): void { + vi.stubEnv("LOKI_ENDPOINT", endpoint); + vi.stubEnv("LOKI_USERNAME", withAuth ? USERNAME : ""); + vi.stubEnv("LOKI_PASSWORD", withAuth ? PASSWORD : ""); + resetLoggingConfig(); +} + +function buffer(count: number): void { + for (let i = 0; i < count; i++) emitEvent(makeEvent()); +} + +beforeEach(() => { + _resetLokiTransportForTests(); + safeFetchMock.mockReset(); + useRealSafeFetch.value = false; + stderrLines = []; + stderrSpy = vi + .spyOn(process.stderr, "write") + .mockImplementation((chunk: string | Uint8Array) => { + stderrLines.push(String(chunk)); + return true; + }); + stdoutSpy = vi.spyOn(process.stdout, "write").mockReturnValue(true); +}); + +afterEach(() => { + stderrSpy.mockRestore(); + stdoutSpy.mockRestore(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + resetLoggingConfig(); + _resetLokiTransportForTests(); +}); + +describe("resolveLokiPushTarget", () => { + it.each([ + ["http://loki:3100", "http://loki:3100/loki/api/v1/push"], + ["http://loki:3100/", "http://loki:3100/loki/api/v1/push"], + ["http://loki:3100/loki/api/v1/push", "http://loki:3100/loki/api/v1/push"], + ["http://loki:3100/loki/api/v1/push/", "http://loki:3100/loki/api/v1/push"], + [ + "https://logs.example.com/tenant-a", + "https://logs.example.com/tenant-a/loki/api/v1/push", + ], + [ + "https://logs.example.com/tenant-a/loki/api/v1/push", + "https://logs.example.com/tenant-a/loki/api/v1/push", + ], + [" http://10.0.0.5:3100 ", "http://10.0.0.5:3100/loki/api/v1/push"], + ])("%s pushes to %s", (endpoint, expected) => { + const resolved = resolveLokiPushTarget(endpoint); + expect(resolved.ok).toBe(true); + if (resolved.ok) expect(resolved.target.url).toBe(expected); + }); + + it("approves exactly the endpoint origin, private addresses included", () => { + const resolved = resolveLokiPushTarget("http://192.168.1.20:3100/"); + expect(resolved).toEqual({ + ok: true, + target: { + url: "http://192.168.1.20:3100/loki/api/v1/push", + origin: "http://192.168.1.20:3100", + }, + }); + }); + + it.each([ + "http://169.254.169.254", + "http://169.254.10.1:3100", + "http://0.0.0.0:3100", + "http://[fe80::1]:3100", + ])("refuses the never-grantable host %s", (endpoint) => { + const resolved = resolveLokiPushTarget(endpoint); + expect(resolved.ok).toBe(false); + if (!resolved.ok) expect(resolved.reason).toBe("never_grantable"); + }); + + it.each([ + "loki:3100", + "ftp://loki:3100", + "http://user:pw@loki:3100", + "http://loki:3100?token=abc", + ])("refuses the malformed endpoint %s", (endpoint) => { + const resolved = resolveLokiPushTarget(endpoint); + expect(resolved.ok).toBe(false); + if (!resolved.ok) { + expect(resolved.reason).toBe("invalid_endpoint"); + expect(resolved.shown).not.toContain("pw"); + expect(resolved.shown).not.toContain("abc"); + } + }); +}); + +describe("flushLokiBuffer — dial path", () => { + it("dials a private endpoint as an operator-approved origin, not through the public pin", async () => { + configure("http://10.0.0.5:3100/loki/api/v1/push"); + safeFetchMock.mockResolvedValue(new Response(null, { status: 204 })); + buffer(2); + + await flushLokiBuffer(); + + expect(safeFetchMock).toHaveBeenCalledTimes(1); + const [url, init, opts] = safeFetchMock.mock.calls[0]; + expect(url).toBe("http://10.0.0.5:3100/loki/api/v1/push"); + expect(init.headers.Authorization).toBe(`Basic ${BASIC}`); + expect(opts.operatorApprovedPrivateOrigin).toBe("http://10.0.0.5:3100"); + expect(opts.requirePublicHost).toBeUndefined(); + expect(JSON.parse(init.body).streams[0].values).toHaveLength(2); + expect(stderrLines).toEqual([]); + }); + + it("never dials a metadata endpoint and says why", async () => { + configure("http://169.254.169.254:3100"); + buffer(3); + + await flushLokiBuffer(); + + expect(safeFetchMock).not.toHaveBeenCalled(); + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("never_grantable"); + expect(stderrLines[0]).toContain("3 events dropped"); + }); +}); + +describe("flushLokiBuffer — failures reach stderr, once per window", () => { + it("reports a non-2xx answer with origin, status and dropped count", async () => { + configure("https://logs.example.com/loki/api/v1/push/loki/api/v1/push"); + safeFetchMock.mockResolvedValue( + new Response("404 page not found", { status: 404 }), + ); + buffer(4); + + await flushLokiBuffer(); + + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("https://logs.example.com"); + expect(stderrLines[0]).toContain("HTTP 404"); + expect(stderrLines[0]).toContain("4 events dropped"); + }); + + it("reports a thrown error by class and socket code, never by message", async () => { + configure("http://loki.lan:3100"); + const cause = Object.assign(new Error("connect ECONNREFUSED"), { + code: "ECONNREFUSED", + }); + safeFetchMock.mockRejectedValue( + new SafeFetchError( + `safeFetch network error: http://${USERNAME}:${PASSWORD}@loki.lan`, + "network", + { cause }, + ), + ); + buffer(1); + + await flushLokiBuffer(); + + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("http://loki.lan:3100"); + expect(stderrLines[0]).toContain("network ECONNREFUSED"); + expect(stderrLines[0]).toContain("1 event dropped"); + }); + + it("writes one line per reason per window and carries the suppressed count forward", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + configure("http://loki.lan:3100"); + safeFetchMock.mockImplementation( + async () => new Response(null, { status: 503 }), + ); + + for (let i = 0; i < 5; i++) { + buffer(2); + await flushLokiBuffer(); + vi.advanceTimersByTime(5_000); + } + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain("2 events dropped"); + + // A different reason is not muted by the first one's window. + safeFetchMock.mockImplementationOnce( + async () => new Response(null, { status: 401 }), + ); + buffer(1); + await flushLokiBuffer(); + expect(stderrLines).toHaveLength(2); + expect(stderrLines[1]).toContain("HTTP 401"); + + vi.advanceTimersByTime(LOKI_FAILURE_NOTICE_INTERVAL_MS); + buffer(2); + await flushLokiBuffer(); + expect(stderrLines).toHaveLength(3); + expect(stderrLines[2]).toContain("HTTP 503"); + // Four suppressed batches of two plus this batch of two. + expect(stderrLines[2]).toContain("10 events dropped"); + }); + + it("never prints the credentials or the basic-auth header", async () => { + configure("http://loki.lan:3100"); + safeFetchMock.mockResolvedValueOnce(new Response(null, { status: 403 })); + safeFetchMock.mockRejectedValueOnce( + new SafeFetchError(`failed with Basic ${BASIC} ${PASSWORD}`, "timeout"), + ); + buffer(1); + await flushLokiBuffer(); + buffer(1); + await flushLokiBuffer(); + + expect(stderrLines).toHaveLength(2); + const all = stderrLines.join(""); + expect(all).not.toContain(PASSWORD); + expect(all).not.toContain(USERNAME); + expect(all).not.toContain(BASIC); + }); + + it("stays silent on success", async () => { + configure("http://loki.lan:3100"); + safeFetchMock.mockResolvedValue(new Response(null, { status: 204 })); + buffer(1); + await flushLokiBuffer(); + expect(stderrLines).toEqual([]); + }); + + it("does not feed its own notice back into the Loki buffer", async () => { + configure("http://loki.lan:3100"); + safeFetchMock.mockResolvedValue(new Response(null, { status: 500 })); + buffer(1); + await flushLokiBuffer(); + expect(stderrLines).toHaveLength(1); + + // Nothing was buffered by the failure, so the next flush has nothing + // to send. + safeFetchMock.mockClear(); + await flushLokiBuffer(); + expect(safeFetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("flushLokiBuffer — real dial to a loopback Loki", () => { + let server: Server; + let received: Array<{ url: string; auth: string | undefined }>; + let status: number; + + beforeEach(async () => { + useRealSafeFetch.value = true; + received = []; + status = 204; + server = createServer((req, res) => { + received.push({ url: req.url ?? "", auth: req.headers.authorization }); + req.resume(); + req.on("end", () => { + res.statusCode = status; + res.end(); + }); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + function endpoint(path = ""): string { + const { port } = server.address() as AddressInfo; + return `http://127.0.0.1:${port}${path}`; + } + + it("delivers to a non-public address given as the full push URL", async () => { + configure(endpoint("/loki/api/v1/push")); + buffer(1); + + await flushLokiBuffer(); + + expect(received).toEqual([ + { url: "/loki/api/v1/push", auth: `Basic ${BASIC}` }, + ]); + expect(stderrLines).toEqual([]); + }); + + it("reports the 404 a wrong path produces instead of swallowing it", async () => { + status = 404; + configure(endpoint("/wrong")); + buffer(2); + + await flushLokiBuffer(); + + expect(received).toHaveLength(1); + expect(received[0].url).toBe("/wrong/loki/api/v1/push"); + expect(stderrLines).toHaveLength(1); + expect(stderrLines[0]).toContain(`${endpoint()} failed (HTTP 404)`); + }); +}); diff --git a/src/lib/logging/transports.ts b/src/lib/logging/transports.ts index 4bbddb090..0e1b4cb02 100644 --- a/src/lib/logging/transports.ts +++ b/src/lib/logging/transports.ts @@ -2,7 +2,8 @@ import type { WideEvent } from "./types"; import { getLoggingConfig } from "./config"; import { shouldEmit } from "./sampler"; import { appendLogEvent } from "./in-memory-buffer"; -import { safeFetch } from "@/lib/safe-fetch"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; +import { canonicalOrigin } from "@/lib/private-origin-policy"; /** Event auf stdout als einzelne JSON-Zeile schreiben */ function emitToStdout(event: WideEvent): void { @@ -31,7 +32,133 @@ function initLokiTransport(): void { if (lokiFlushTimer.unref) lokiFlushTimer.unref(); } -async function flushLokiBuffer(): Promise { +const LOKI_PUSH_PATH = "/loki/api/v1/push"; +const LOKI_PUSH_TIMEOUT_MS = 10_000; + +export interface LokiPushTarget { + /** The URL the batch is POSTed to. */ + url: string; + /** + * The exact origin handed to `safeFetch` as the operator approval. Also the + * only part of the endpoint that ever appears in a failure line: it carries + * no userinfo, path or query by construction. + */ + origin: string; +} + +export type LokiTargetResolution = + | { ok: true; target: LokiPushTarget } + | { + ok: false; + reason: "invalid_endpoint" | "never_grantable"; + shown: string; + }; + +/** + * Turn `LOKI_ENDPOINT` into the push URL and the origin it may dial. + * + * Both spellings an operator reaches for work: the base URL + * (`http://loki:3100`, the push path is appended) and the full push URL + * (`http://loki:3100/loki/api/v1/push`, used as is, trailing slash or not). + * Appending unconditionally turned the second form into + * `/loki/api/v1/push/loki/api/v1/push` and a 404. + * + * Why the endpoint dials as an operator-approved origin rather than through + * the public-host pin: `LOKI_ENDPOINT` is read from the server environment in + * `config.ts` and nowhere else. No request field, settings row or admin form + * can set it, so there is no user-supplied host to defend against, and the + * public-only pin was refusing the common deployment (a Loki on the same LAN + * or Docker network) without a word. The exact origin still goes through the + * pinned operator resolver in `safeFetch` with redirects forbidden, and the + * never-grantable floor holds: an endpoint whose literal host is the + * unspecified address, link-local or the metadata range is refused here + * (`canonicalOrigin`), and a name that resolves there is dropped at dial time. + */ +export function resolveLokiPushTarget(endpoint: string): LokiTargetResolution { + let url: URL; + try { + url = new URL(endpoint.trim()); + } catch { + return { ok: false, reason: "invalid_endpoint", shown: "(unparseable)" }; + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { ok: false, reason: "invalid_endpoint", shown: url.protocol }; + } + // `url.origin` never carries userinfo, so it is safe to print even when + // the endpoint itself is refused for carrying credentials. + if (url.username || url.password || url.search || url.hash) { + return { ok: false, reason: "invalid_endpoint", shown: url.origin }; + } + const origin = canonicalOrigin(url.origin); + if (!origin) { + return { ok: false, reason: "never_grantable", shown: url.origin }; + } + const basePath = url.pathname.replace(/\/+$/, ""); + const pushPath = basePath.endsWith(LOKI_PUSH_PATH) + ? basePath + : `${basePath}${LOKI_PUSH_PATH}`; + return { ok: true, target: { url: `${origin}${pushPath}`, origin } }; +} + +/** + * Failure notices for the Loki push, written straight to stderr. + * + * Never through `emitEvent`: a failing Loki push that reported itself as a + * wide event would buffer that event for the same failing push. At most one + * line per reason per window, so a Loki that is down for an hour costs twelve + * lines, not seven hundred. Events dropped while a reason is quiet are counted + * and reported on its next line, so the numbers add up. + */ +export const LOKI_FAILURE_NOTICE_INTERVAL_MS = 5 * 60 * 1000; +const lokiFailureNotices = new Map< + string, + { lastAt: number; droppedSinceLast: number } +>(); + +function reportLokiFailure( + shownEndpoint: string, + reason: string, + droppedEvents: number, +): void { + const now = Date.now(); + const entry = lokiFailureNotices.get(reason); + if (entry && now - entry.lastAt < LOKI_FAILURE_NOTICE_INTERVAL_MS) { + entry.droppedSinceLast += droppedEvents; + return; + } + const dropped = droppedEvents + (entry?.droppedSinceLast ?? 0); + lokiFailureNotices.set(reason, { lastAt: now, droppedSinceLast: 0 }); + process.stderr.write( + `[logging] Loki push to ${shownEndpoint} failed (${reason}); ` + + `${dropped} event${dropped === 1 ? "" : "s"} dropped. ` + + `Repeats of this failure are reported at most every 5 minutes.\n`, + ); +} + +/** + * The failure reason as a short class, never the error message: messages + * from the fetch stack can embed the target URL, and this line goes to stderr + * unredacted. A socket error code (`ECONNREFUSED`, `ENOTFOUND`) is kept + * because it is the part that tells an operator what to fix. + */ +function classifyLokiError(err: unknown): string { + const kind = err instanceof SafeFetchError ? err.kind : "error"; + let cause: unknown = err instanceof SafeFetchError ? err.cause : err; + for ( + let depth = 0; + depth < 4 && cause && typeof cause === "object"; + depth++ + ) { + const code = (cause as { code?: unknown }).code; + if (typeof code === "string" && /^[A-Z][A-Z0-9_]{1,40}$/.test(code)) { + return `${kind} ${code}`; + } + cause = (cause as { cause?: unknown }).cause; + } + return kind; +} + +export async function flushLokiBuffer(): Promise { if (lokiBuffer.length === 0) return; const config = getLoggingConfig(); if (!config.lokiEndpoint) return; @@ -39,6 +166,13 @@ async function flushLokiBuffer(): Promise { const batch = lokiBuffer; lokiBuffer = []; + const resolved = resolveLokiPushTarget(config.lokiEndpoint); + if (!resolved.ok) { + reportLokiFailure(resolved.shown, resolved.reason, batch.length); + return; + } + const { target } = resolved; + const streams = [ { stream: { @@ -64,21 +198,41 @@ async function flushLokiBuffer(): Promise { ); } + let res: Response; try { - await safeFetch( - `${config.lokiEndpoint}/loki/api/v1/push`, + res = await safeFetch( + target.url, { method: "POST", headers, body: JSON.stringify({ streams }), }, - // Operator-configured Loki endpoint — pin the connect-time IP - // against DNS rebinding. - { timeoutMs: 10_000, requirePublicHost: true }, + // Operator-configured endpoint: dial exactly its origin through the + // pinned operator resolver (see `resolveLokiPushTarget`). + { + timeoutMs: LOKI_PUSH_TIMEOUT_MS, + operatorApprovedPrivateOrigin: target.origin, + }, ); - } catch { - // Events gehen verloren — akzeptabel fuer Logging + } catch (err) { + // The batch is already detached from the buffer; say so rather than + // losing it without a trace. + reportLokiFailure(target.origin, classifyLokiError(err), batch.length); + return; } + + // Loki answers 204. Anything else, including a 3xx the forbidden redirect + // left unfollowed, means the batch did not land. + if (!res.ok) { + reportLokiFailure(target.origin, `HTTP ${res.status}`, batch.length); + } + await res.body?.cancel().catch(() => {}); +} + +/** Test helper: clear the buffer and the failure-notice windows. */ +export function _resetLokiTransportForTests(): void { + lokiBuffer = []; + lokiFailureNotices.clear(); } /** 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 03/26] 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