From bcf2322f65d9392b40b2188e25b7e88b9f4b6a7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 20:51:29 +0200 Subject: [PATCH] feat(02-12.1): vary the record echo on the headers that produced it A shared cache is one reverse proxy away, and without Vary the first one to appear would key a delegated record response on URL alone and hand it to the next request under a different context. Appends rather than replaces, so a route that already varies keeps what it had. --- .../__tests__/record-session-fence.test.ts | 58 +++++++++++++++++++ src/lib/sharing/record-session-fence.ts | 27 +++++++++ 2 files changed, 85 insertions(+) diff --git a/src/lib/sharing/__tests__/record-session-fence.test.ts b/src/lib/sharing/__tests__/record-session-fence.test.ts index ed653122b..630d47eb2 100644 --- a/src/lib/sharing/__tests__/record-session-fence.test.ts +++ b/src/lib/sharing/__tests__/record-session-fence.test.ts @@ -330,6 +330,64 @@ describe("the contract module is safe in the browser bundle", () => { }); }); +describe("attachRecordContextEcho", () => { + async function attach( + context: { epoch: number; scope: string | null } | undefined, + initial?: Record, + ): Promise { + const { attachRecordContextEcho } = + await import("@/lib/sharing/record-session-fence"); + const headers = new Headers(initial); + attachRecordContextEcho(headers, context); + return headers; + } + + it("declares that the answer varies on the two fence headers", async () => { + // Without this a shared cache — one reverse proxy away — would key a + // delegated record response on URL alone and serve it to the next request + // for the same URL under a different context. One person's record handed + // to another by an infrastructure change nobody thought was a code change. + const headers = await attach({ epoch: 4, scope: "owner-1" }); + const vary = (headers.get("Vary") ?? "") + .split(",") + .map((part) => part.trim().toLowerCase()); + expect(vary).toContain(RECORD_EPOCH_HEADER); + expect(vary).toContain(RECORD_SCOPE_HEADER); + }); + + it("keeps a Vary the route had already set", async () => { + const headers = await attach( + { epoch: 1, scope: null }, + { Vary: "Accept-Encoding" }, + ); + const vary = (headers.get("Vary") ?? "") + .split(",") + .map((part) => part.trim().toLowerCase()); + expect(vary).toContain("accept-encoding"); + expect(vary).toContain(RECORD_EPOCH_HEADER); + }); + + it("does not repeat a header the route already varied on", async () => { + const headers = await attach( + { epoch: 1, scope: null }, + { Vary: `Accept-Encoding, ${RECORD_EPOCH_HEADER.toUpperCase()}` }, + ); + const vary = (headers.get("Vary") ?? "") + .split(",") + .map((part) => part.trim().toLowerCase()); + expect(vary.filter((v) => v === RECORD_EPOCH_HEADER)).toHaveLength(1); + }); + + it("adds nothing at all when no record scope was resolved", async () => { + // The response carries no echo, so there is nothing for a cache to vary + // on — and claiming otherwise would fragment the cache key of every public + // route for no reason. + const headers = await attach(undefined); + expect(headers.get("Vary")).toBeNull(); + expect(headers.get(RECORD_EPOCH_HEADER)).toBeNull(); + }); +}); + describe("assertRecordSessionFence", () => { beforeEach(() => { headerJar.clear(); diff --git a/src/lib/sharing/record-session-fence.ts b/src/lib/sharing/record-session-fence.ts index 61ab2cdb8..2d84d04e0 100644 --- a/src/lib/sharing/record-session-fence.ts +++ b/src/lib/sharing/record-session-fence.ts @@ -103,6 +103,33 @@ export function attachRecordContextEcho( if (!context) return; headers.set(RECORD_EPOCH_HEADER, String(context.epoch)); headers.set(RECORD_SCOPE_HEADER, recordScopeHeaderValue(context.scope)); + // And declare that the answer DEPENDS on those two request headers. + // + // Nothing between the browser and this app caches today — the responses that + // reach here are private and same-origin. But a shared cache is one reverse + // proxy away, and without `Vary` the first one to appear would key a + // delegated record response on URL alone and hand it to the next request for + // the same URL under a different context. That is one person's health record + // served to another, produced by an infrastructure change nobody thought was + // a code change. + // + // Appended rather than assigned: a route that already varies on something + // (`Accept-Encoding`, a locale header) must keep it. Case-insensitive + // de-duplication, because a value repeated in two spellings is the sort of + // header that makes a proxy give up and cache nothing. + const existing = headers + .get("Vary") + ?.split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + const names = new Set((existing ?? []).map((part) => part.toLowerCase())); + const merged = [...(existing ?? [])]; + for (const header of [RECORD_EPOCH_HEADER, RECORD_SCOPE_HEADER]) { + if (names.has(header.toLowerCase())) continue; + names.add(header.toLowerCase()); + merged.push(header); + } + headers.set("Vary", merged.join(", ")); } /**