From 59359738c84547d3188a23a44bfb67e345fbff32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 6 Aug 2026 17:59:07 +0200 Subject: [PATCH] feat(02-12.1): decide record-session context in one place - add an import-free contract module both bundles can name, holding the header names, the parse and the verdict - add the server fence: header read, wide-event stamp, and the refusal - add RecordSessionChangedError as a third sibling on the existing sharing-error arm, so the 409 needs no new serialisation branch --- .../acting-account-boundary-guard.test.ts | 14 + src/lib/api-handler.ts | 44 ++ src/lib/logging/event-builder.ts | 19 + src/lib/logging/types.ts | 12 + .../__tests__/record-session-fence.test.ts | 439 ++++++++++++++++++ .../sharing/record-session-fence-contract.ts | 233 ++++++++++ src/lib/sharing/record-session-fence.ts | 134 ++++++ 7 files changed, 895 insertions(+) create mode 100644 src/lib/sharing/__tests__/record-session-fence.test.ts create mode 100644 src/lib/sharing/record-session-fence-contract.ts create mode 100644 src/lib/sharing/record-session-fence.ts diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index df45e02db..42cf7af48 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -94,6 +94,20 @@ describe("the acting-account carrier is read in one place", () => { // whole auth context for exactly this reason: had it taken the id, the // route would name the column too, and the list would grow per surface. "lib/sharing/account-access.ts", + // v1.37.0 — the record-session fence COMPARES it and never writes it. It + // reads the value off the `AuthContext` the resolver already holds rather + // than from the row, which is the property the fence rests on: the scope + // the handler serves under and the epoch the fence validated are one row + // read, so they cannot straddle a switch. A version of this file that + // resolved its own session would be a second answer to "whose record is + // this" and would also be wrong more often. + "lib/sharing/record-session-fence.ts", + // v1.37.0 — names the column in prose only. The matcher does not exempt + // comments and is left that way on purpose (see the note above), so the + // module that explains the fence has to be listed. It imports nothing at + // all, which the fence's own unit test asserts, so it can read no column + // even in principle. + "lib/sharing/record-session-fence-contract.ts", ].sort(); it("no other file reads or writes the carrier column", () => { diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 92e0addb9..e434a4520 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -37,6 +37,8 @@ import { type GrantNeed, } from "./sharing/grants"; import type { ShareScope } from "./sharing/scope"; +import { RECORD_FENCE_ERROR_CODE } from "./sharing/record-session-fence-contract"; +import { assertRecordSessionFence } from "./sharing/record-session-fence"; /** * HTTP methods a read-only credential may use on the REST surface. A request @@ -139,6 +141,38 @@ export class SharingNotPermittedError extends SharingAuthError { } } +/** + * v1.37.0 — "the record this session is on is not the record you asserted." + * + * A third sibling rather than a branch, so it rides the existing + * `SharingAuthError` arm below with no new serialisation code. The status is + * the one thing that differs and it differs on purpose: 409, not 403, because + * this is not a refusal of access. The caller may well be entitled to both + * records. What failed is agreement about WHICH one, and the instruction the + * code carries is "reconcile through `/api/auth/me` and try again", not "you + * have lost this record". + * + * That distinction is load-bearing for deploy compatibility: the currently + * shipped bundle reacts to `sharing.access.denied` by leaving the record and + * hard-navigating, which is exactly the wrong response to a transient + * disagreement and exactly the right one for a bundle too old to reconcile. See + * `src/lib/sharing/record-session-fence-contract.ts` for why the fence hands + * each bundle the code it can act on. + * + * The body carries `meta.errorCode` and nothing else — no current epoch, no + * current scope. A client may learn its context from exactly two responses + * (`GET /api/auth/me` and `POST /api/account/switch`), and putting the truth in + * this refusal would open a third path, one that arrives on the very requests + * whose context is in doubt. + */ +export class RecordSessionChangedError extends SharingAuthError { + readonly errorCode = RECORD_FENCE_ERROR_CODE; + constructor() { + super(409, "The record this session is on has changed"); + this.name = "RecordSessionChangedError"; + } +} + /** * Wraps an API route handler with Wide Event logging. * Creates a WideEventBuilder, runs the handler inside AsyncLocalStorage, @@ -393,6 +427,16 @@ export type AuthContext = { * below reads it. */ readonly actingAsUserId?: string | null; + /** + * v1.37.0 — how many times this session's record selector has moved. The + * record-session fence compares it against what the request asserted. + * + * Optional for the same reason as `actingAsUserId` above, and `undefined` + * reads as `0` — which is the fence's exemption, so a hand-built test + * context keeps meaning "a session that never switched". Read only by the + * fence. + */ + readonly recordEpoch?: number; }; user: User; /** diff --git a/src/lib/logging/event-builder.ts b/src/lib/logging/event-builder.ts index c85f66ed9..7af2d2d77 100644 --- a/src/lib/logging/event-builder.ts +++ b/src/lib/logging/event-builder.ts @@ -118,6 +118,25 @@ export class WideEventBuilder { return this; } + /** + * v1.37.0 — record the record context this request resolved. + * + * Merges for the same reason `setActingAs` does. Written by exactly one + * caller, the record-session fence, and read back by `apiHandler` to attach + * the response echo. `apiHandler` deliberately does NOT derive the context + * itself: doing so would put a session read on every public route and make + * the echo a second derivation rather than a report of the one that was used. + */ + setRecordContext(context: { epoch: number; scope: string | null }): this { + this.event.auth = { ...(this.event.auth ?? {}), record_context: context }; + return this; + } + + /** The record context the fence stamped, or undefined if it never ran. */ + getRecordContext(): { epoch: number; scope: string | null } | undefined { + return this.event.auth?.record_context; + } + setProviderWorkAuthority(authority: ProviderWorkAuthority): this { this.event.auth = { ...(this.event.auth ?? {}), diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index 73d6ff47d..727f5440f 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -80,6 +80,18 @@ export interface WideEvent { delegated_generation?: "suppressed"; /** Durable authority copied into provider queue payloads. */ provider_work_authority?: ProviderWorkAuthority; + /** + * v1.37.0 — the record context this request was actually served under, as + * the record-session fence read it off the session row. + * + * Stamped by the fence on every call it makes, pass or refuse, and by + * nothing else. `apiHandler` copies it onto two response headers so the + * browser can discard a response that resolved a different record than the + * one it is now showing. Present exactly on the responses that resolved a + * record scope; absent everywhere else, which is what makes "no echo" mean + * "nothing about a record was decided here" rather than "unknown". + */ + record_context?: { epoch: number; scope: string | null }; }; // Business-Daten (was wurde getan) diff --git a/src/lib/sharing/__tests__/record-session-fence.test.ts b/src/lib/sharing/__tests__/record-session-fence.test.ts new file mode 100644 index 000000000..ed653122b --- /dev/null +++ b/src/lib/sharing/__tests__/record-session-fence.test.ts @@ -0,0 +1,439 @@ +/** + * The record-session fence: every arm of the verdict, and the refusals it + * raises. + * + * The verdict is a pure function, so this file can be exhaustive about it in a + * way the integration proofs cannot. It also holds two structural assertions + * that belong nowhere else: + * + * * the contract module has no `import` statement at all — the property that + * lets the browser bundle name the same header constants without dragging + * `next/headers` or Prisma in behind them. It typechecks either way, so + * only a check like this one or a full `pnpm build` catches a regression; + * * the asserted-scope length bound equals `MAX_SELECTOR_LENGTH`. The + * contract module deliberately does not import that constant (importing + * `acting-carrier.ts` is the exact poisoning above), so nothing but an + * assertion keeps the two numbers together. + * + * Break this file by changing the `unfenced-client` arm of + * `recordSessionFenceVerdict` to return `"pass"`: every absent-header case on a + * fenced session fails. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { MAX_SELECTOR_LENGTH } from "@/lib/auth/acting-carrier"; +import { + MAX_ASSERTED_SCOPE_LENGTH, + RECORD_EPOCH_HEADER, + RECORD_FENCE_BOOTSTRAP, + RECORD_FENCE_ERROR_CODE, + RECORD_SCOPE_HEADER, + RECORD_SCOPE_SELF, + parseAssertedContext, + recordScopeHeaderValue, + recordSessionFenceVerdict, + type AssertedRecordContext, + type RecordFenceVerdict, +} from "@/lib/sharing/record-session-fence-contract"; + +const headerJar = new Map(); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), +})); + +const OWNER = "owner-account-id"; +const OTHER = "other-account-id"; + +function asserted(epoch: number, scope: string | null): AssertedRecordContext { + return { kind: "asserted", epoch, scope }; +} + +describe("recordSessionFenceVerdict", () => { + type Case = { + name: string; + input: Parameters[0]; + want: RecordFenceVerdict; + }; + + const CASES: Case[] = [ + { + name: "bearer passes with no assertion at all", + input: { + transport: "bearer", + sessionEpoch: 0, + sessionScope: null, + asserted: { kind: "absent" }, + }, + want: "pass", + }, + { + name: "bearer passes even against a nonsense assertion", + input: { + transport: "bearer", + sessionEpoch: 7, + sessionScope: OWNER, + asserted: asserted(1, OTHER), + }, + want: "pass", + }, + { + name: "a session that never switched passes with no header", + input: { + transport: "cookie", + sessionEpoch: 0, + sessionScope: null, + asserted: { kind: "absent" }, + }, + want: "pass", + }, + { + name: "a session that never switched passes on the bootstrap sentinel", + input: { + transport: "cookie", + sessionEpoch: 0, + sessionScope: null, + asserted: { kind: "unparseable" }, + }, + want: "pass", + }, + { + name: "a fenced session with a matching epoch and scope passes", + input: { + transport: "cookie", + sessionEpoch: 3, + sessionScope: OWNER, + asserted: asserted(3, OWNER), + }, + want: "pass", + }, + { + name: "a fenced session back on its own record passes on a matching self assertion", + input: { + transport: "cookie", + sessionEpoch: 4, + sessionScope: null, + asserted: asserted(4, null), + }, + want: "pass", + }, + { + name: "a stale epoch is stale", + input: { + transport: "cookie", + sessionEpoch: 3, + sessionScope: OWNER, + asserted: asserted(2, OWNER), + }, + want: "stale", + }, + { + name: "an epoch from the future is stale", + input: { + transport: "cookie", + sessionEpoch: 3, + sessionScope: OWNER, + asserted: asserted(4, OWNER), + }, + want: "stale", + }, + { + name: "a matching epoch with a mismatched scope is stale", + input: { + transport: "cookie", + sessionEpoch: 3, + sessionScope: OWNER, + asserted: asserted(3, OTHER), + }, + want: "stale", + }, + { + name: "asserting a record while the session is on its own is stale", + input: { + transport: "cookie", + sessionEpoch: 5, + sessionScope: null, + asserted: asserted(5, OWNER), + }, + want: "stale", + }, + { + name: "asserting self while the session is inside a record is stale", + input: { + transport: "cookie", + sessionEpoch: 5, + sessionScope: OWNER, + asserted: asserted(5, null), + }, + want: "stale", + }, + { + name: "an unparseable assertion on a fenced session is stale, not unfenced", + input: { + transport: "cookie", + sessionEpoch: 1, + sessionScope: OWNER, + asserted: { kind: "unparseable" }, + }, + want: "stale", + }, + { + name: "an absent assertion on a fenced session is an unfenced client", + input: { + transport: "cookie", + sessionEpoch: 1, + sessionScope: OWNER, + asserted: { kind: "absent" }, + }, + want: "unfenced-client", + }, + { + name: "a session that has LEFT a record stays fenced", + input: { + transport: "cookie", + sessionEpoch: 2, + sessionScope: null, + asserted: { kind: "absent" }, + }, + want: "unfenced-client", + }, + ]; + + it("covers a non-zero number of cases", () => { + // A table-driven test whose table emptied would report success. + expect(CASES.length).toBeGreaterThan(10); + }); + + for (const c of CASES) { + it(c.name, () => { + expect(recordSessionFenceVerdict(c.input)).toBe(c.want); + }); + } + + it("keeps the exemption's second clause honest", () => { + // Migration 0302 makes `scope !== null` imply `epoch >= 1`, so the second + // conjunct of the exemption is redundant against real data. It is kept + // because a redundant clause over an authorization boundary is the right + // direction to be wrong in — and this asserts it actually does something + // if the data ever disagrees with the migration. + expect( + recordSessionFenceVerdict({ + transport: "cookie", + sessionEpoch: 0, + sessionScope: OWNER, + asserted: { kind: "absent" }, + }), + ).toBe("unfenced-client"); + }); +}); + +describe("parseAssertedContext", () => { + it("reads no headers as absent", () => { + expect(parseAssertedContext(null, null)).toEqual({ kind: "absent" }); + }); + + it("reads half an assertion as unparseable, never as absent", () => { + // Half an assertion comes from a client that knows about the fence, so it + // must take the reconcile arm and not the leave-the-record arm. + expect(parseAssertedContext("3", null)).toEqual({ kind: "unparseable" }); + expect(parseAssertedContext(null, RECORD_SCOPE_SELF)).toEqual({ + kind: "unparseable", + }); + }); + + it("reads the bootstrap sentinel as unparseable", () => { + expect( + parseAssertedContext(RECORD_FENCE_BOOTSTRAP, RECORD_FENCE_BOOTSTRAP), + ).toEqual({ kind: "unparseable" }); + }); + + it.each([ + ["not-a-number", "nonsense"], + ["-1", "negative"], + ["3.5", "fractional"], + ["1e3", "exponential"], + [" 3", "padded"], + ["", "empty"], + ["9".repeat(40), "over-long"], + ])("reads %s as unparseable (%s)", (value) => { + expect(parseAssertedContext(value, RECORD_SCOPE_SELF)).toEqual({ + kind: "unparseable", + }); + }); + + it("reads an over-long scope as unparseable before comparing it", () => { + expect( + parseAssertedContext("3", "x".repeat(MAX_ASSERTED_SCOPE_LENGTH + 1)), + ).toEqual({ kind: "unparseable" }); + }); + + it("reads an empty scope as unparseable", () => { + expect(parseAssertedContext("3", "")).toEqual({ kind: "unparseable" }); + }); + + it("reads the self sentinel as the null scope", () => { + expect(parseAssertedContext("0", RECORD_SCOPE_SELF)).toEqual({ + kind: "asserted", + epoch: 0, + scope: null, + }); + }); + + it("reads an account id as itself", () => { + expect(parseAssertedContext("12", OWNER)).toEqual({ + kind: "asserted", + epoch: 12, + scope: OWNER, + }); + }); + + it("round-trips a scope through the header value helper", () => { + for (const scope of [null, OWNER]) { + expect(parseAssertedContext("1", recordScopeHeaderValue(scope))).toEqual({ + kind: "asserted", + epoch: 1, + scope, + }); + } + }); +}); + +describe("the contract module is safe in the browser bundle", () => { + const CONTRACT = join( + process.cwd(), + "src/lib/sharing/record-session-fence-contract.ts", + ); + + it("has no import statement at all", () => { + const src = readFileSync(CONTRACT, "utf8"); + // Non-zero proof first: if the file were empty or misspelled, an + // "it contains no imports" assertion would pass for the wrong reason. + expect(src.length).toBeGreaterThan(1000); + expect(src).toContain("RECORD_EPOCH_HEADER"); + expect(src).not.toMatch(/^\s*import[\s{*]/m); + expect(src).not.toMatch(/\bfrom\s+["']/); + expect(src).not.toMatch(/\brequire\s*\(/); + }); + + it("bounds an asserted scope at the same length as an account selector", () => { + expect(MAX_ASSERTED_SCOPE_LENGTH).toBe(MAX_SELECTOR_LENGTH); + }); + + it("names the two headers on the cookie transport's own namespace", () => { + expect(RECORD_EPOCH_HEADER).toBe("x-healthlog-record-epoch"); + expect(RECORD_SCOPE_HEADER).toBe("x-healthlog-record-scope"); + expect(RECORD_FENCE_ERROR_CODE).toBe("sharing.session.changed"); + }); +}); + +describe("assertRecordSessionFence", () => { + beforeEach(() => { + headerJar.clear(); + vi.resetModules(); + }); + + async function run(input: { + authMethod: "cookie" | "bearer"; + recordEpoch?: number; + actingAsUserId?: string | null; + }): Promise<{ error: unknown; stamped: unknown }> { + const { assertRecordSessionFence } = + await import("@/lib/sharing/record-session-fence"); + let stamped: unknown; + const { eventStorage } = await import("@/lib/logging/context"); + const { WideEventBuilder } = await import("@/lib/logging/event-builder"); + const evt = new WideEventBuilder(); + let error: unknown = null; + await eventStorage.run(evt, async () => { + try { + await assertRecordSessionFence({ + session: { + id: "session-1", + expiresAt: new Date(Date.now() + 60_000), + actingAsUserId: input.actingAsUserId ?? null, + recordEpoch: input.recordEpoch, + }, + user: { id: "delegate" } as never, + authMethod: input.authMethod, + }); + } catch (err) { + error = err; + } + stamped = evt.getRecordContext(); + }); + return { error, stamped }; + } + + it("serves a never-switched cookie session and stamps its context", async () => { + const { error, stamped } = await run({ authMethod: "cookie" }); + expect(error).toBeNull(); + expect(stamped).toEqual({ epoch: 0, scope: null }); + }); + + it("serves a matching assertion and stamps the context it served under", async () => { + headerJar.set(RECORD_EPOCH_HEADER, "4"); + headerJar.set(RECORD_SCOPE_HEADER, OWNER); + const { error, stamped } = await run({ + authMethod: "cookie", + recordEpoch: 4, + actingAsUserId: OWNER, + }); + expect(error).toBeNull(); + expect(stamped).toEqual({ epoch: 4, scope: OWNER }); + }); + + it("throws the 409 on a stale assertion, and still stamps the truth", async () => { + headerJar.set(RECORD_EPOCH_HEADER, "3"); + headerJar.set(RECORD_SCOPE_HEADER, OWNER); + const { error, stamped } = await run({ + authMethod: "cookie", + recordEpoch: 4, + actingAsUserId: OWNER, + }); + expect((error as { statusCode?: number }).statusCode).toBe(409); + expect((error as { errorCode?: string }).errorCode).toBe( + RECORD_FENCE_ERROR_CODE, + ); + // The refusal echoes the real context too, so a client that reconciles on + // the discard path and one that reconciles on the 409 path see the same + // thing on the wire. + expect(stamped).toEqual({ epoch: 4, scope: OWNER }); + }); + + it("throws the 403 a pre-fence bundle recovers from when no header arrives", async () => { + const { error } = await run({ + authMethod: "cookie", + recordEpoch: 1, + actingAsUserId: OWNER, + }); + expect((error as { statusCode?: number }).statusCode).toBe(403); + expect((error as { errorCode?: string }).errorCode).toBe( + "sharing.access.denied", + ); + }); + + it("leaves the Bearer transport alone and echoes nothing on it", async () => { + headerJar.set(RECORD_EPOCH_HEADER, "1"); + headerJar.set(RECORD_SCOPE_HEADER, OTHER); + const { error, stamped } = await run({ + authMethod: "bearer", + recordEpoch: 9, + actingAsUserId: OWNER, + }); + expect(error).toBeNull(); + expect(stamped).toBeUndefined(); + }); + + it("treats an absent recordEpoch as zero, so a hand-built context is unfenced", async () => { + const { error } = await run({ + authMethod: "cookie", + recordEpoch: undefined, + actingAsUserId: null, + }); + expect(error).toBeNull(); + }); +}); diff --git a/src/lib/sharing/record-session-fence-contract.ts b/src/lib/sharing/record-session-fence-contract.ts new file mode 100644 index 000000000..8e4847759 --- /dev/null +++ b/src/lib/sharing/record-session-fence-contract.ts @@ -0,0 +1,233 @@ +/** + * v1.37.0 — what record a request believes it is addressing, as a contract both + * bundles can name. + * + * The problem is agreement, not authorization. `acting_as_user_id` plus the + * grant join already decides whether a caller MAY read a record; nothing here + * touches that. What this decides is whether the record the server is about to + * serve is the record the client formed its intent against. A browser holds + * several tabs on one session; a request that left a suspended tab before a + * switch landed arrives after it, perfectly authorized and asking the wrong + * question. So every cookie request that resolves a record asserts the context + * it believes it is in, and the server refuses when the belief no longer + * matches the session row. + * + * ─── Why this module has no imports ─────────────────────────────────────── + * + * The client has to name the header constants, and both obvious homes poison + * the client bundle: `src/lib/api-handler.ts` imports Prisma at module scope + * and `src/lib/auth/acting-carrier.ts` imports `next/headers`. Either import + * would typecheck cleanly and fail `pnpm build`. So the constants, the parse + * and the verdict live here with ZERO imports — asserted by the unit test, not + * by convention — and the server half lives next door in + * `record-session-fence.ts`. + * + * ─── What the fence does NOT cover ──────────────────────────────────────── + * + * Only the two record resolvers (`requireRecordAuth`, `requireGuardianAuth`) + * and the idempotency wrapper consult it. `requireActorAuth`, bare + * `requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa`, + * `GET /api/auth/me` and `POST /api/account/switch` deliberately do not, and a + * guard test freezes that list: + * + * * `requireActorAuth` is the way OUT of a switch. Fencing it would make an + * unprovable context unrecoverable. It serves the caller's own rows by + * definition, so the selector cannot change what it returns. + * * bare `requireAuth` already refuses outright under an active switch. + * * `/api/auth/me` is the reconciliation bootstrap and has to answer while + * the client's context is unknown — it is where the client LEARNS the + * context it would otherwise have to assert. + * + * That list is about the auth HELPERS, not about routes, and it is not + * absolute: `POST /api/admin/backups/[id]/restore` composes the idempotency + * wrapper OUTSIDE `apiHandler`, so it is fence-visible through the idempotency + * arm even though `requireAdmin` itself is unfenced. That is wanted. An + * idempotent admin write under an unprovable context should be refused like any + * other. + * + * ─── Why there are two refusals and not one ─────────────────────────────── + * + * A stale assertion gets 409 `sharing.session.changed`. An ABSENT assertion on + * a fenced session gets 403 `sharing.access.denied` instead, and the difference + * is a deploy-compatibility decision rather than a taxonomy: + * + * Header-absent means exactly one thing on the wire — a bundle that predates + * the fence. A fence-aware client attaches the headers to every same-origin + * request including the ones issued before `/api/auth/me` has resolved, using + * the `bootstrap` sentinel, so it can never reach the absent arm. And a + * pre-fence bundle cannot act on a 409: `src/lib/api/api-fetch.ts` special-cases + * no status at all, so the tab would render an error card and stay broken until + * a human reloaded. It CAN act on 403 `sharing.access.denied`, because the + * deployed bundle already mounts `SharedRecordGrantLossBridge` + * (`src/components/providers.tsx`), which on the first errored query carrying + * that code leaves the record and hard-navigates to `/` via `landOnRecord` in + * `src/hooks/use-account-switch.ts` — a full document navigation, which serves + * the fence-aware bundle. One reload, self-healing, no new code in the old + * bundle. + * + * So do not "simplify" the two arms into one. The 409 is a *reconcile, do not + * leave* instruction that only a fence-aware bundle can follow; handing it to a + * bundle that cannot follow it is the cliff this split exists to avoid. + */ + +/** The asserted epoch. Cookie transport only; Bearer neither sends nor honours it. */ +export const RECORD_EPOCH_HEADER = "x-healthlog-record-epoch"; + +/** The asserted canonical scope. Cookie transport only. */ +export const RECORD_SCOPE_HEADER = "x-healthlog-record-scope"; + +/** + * The scope value naming the caller's own record. + * + * A sentinel rather than an empty header, so "I assert I am on my own record" + * and "I sent no scope at all" are different bytes on the wire. The fence's two + * refusal arms turn on exactly that distinction. + */ +export const RECORD_SCOPE_SELF = "self"; + +/** + * What a fence-aware client asserts before `/api/auth/me` has told it anything. + * + * It is a PRESENT header, which is the point: it can never be mistaken for a + * pre-fence bundle, so it takes the 409 (reconcile) arm and not the 403 (leave + * the record) arm. On a session that never entered a shared record the + * exemption below serves it normally, so an ordinary sign-in never sees a 409 + * during boot. + */ +export const RECORD_FENCE_BOOTSTRAP = "bootstrap"; + +/** The stable code on the 409. The client branches on this, never on prose. */ +export const RECORD_FENCE_ERROR_CODE = "sharing.session.changed"; + +/** + * Longest asserted scope worth comparing. + * + * Same posture and the same number as `MAX_SELECTOR_LENGTH` in + * `src/lib/auth/acting-carrier.ts` — a bound applied before any parse, so an + * over-long value is refused as such rather than measured. Deliberately NOT + * imported from there: that module pulls `next/headers`, and this one is in the + * client bundle. The unit test asserts the two numbers are equal so they cannot + * drift apart unnoticed. + */ +export const MAX_ASSERTED_SCOPE_LENGTH = 64; + +/** + * Longest asserted epoch worth parsing. A session's selector moves a handful of + * times a day at most; fifteen digits is far past any number this counter can + * reach and short enough that nothing large is ever coerced. + */ +const MAX_ASSERTED_EPOCH_LENGTH = 15; + +/** + * What the two headers said. + * + * `absent` and `unparseable` are separate cases because the fence answers them + * with different status codes — see the two-refusals note above. Collapsing + * them would hand a pre-fence bundle a status it cannot recover from. + */ +export type AssertedRecordContext = + | { kind: "absent" } + | { kind: "unparseable" } + | { kind: "asserted"; epoch: number; scope: string | null }; + +/** What the fence decided. */ +export type RecordFenceVerdict = "pass" | "stale" | "unfenced-client"; + +/** + * Turn the two raw header values into a closed union. + * + * No account-shape validation happens here and none is needed: the scope is + * compared by string equality against the session's real scope, so a bogus + * asserted account simply fails to match and yields `stale`. Adding a validator + * would be a second, weaker statement of what an account id is, and the weaker + * copy is the one that drifts. + */ +export function parseAssertedContext( + epochHeader: string | null, + scopeHeader: string | null, +): AssertedRecordContext { + if (epochHeader === null && scopeHeader === null) return { kind: "absent" }; + + // Half an assertion is not an assertion. A fence-aware client sends both on + // every request, so one-of-two is a client that knows about the fence and got + // it wrong — which is the `stale` arm, not the pre-fence arm. + if (epochHeader === null || scopeHeader === null) + return { kind: "unparseable" }; + + if ( + epochHeader.length > MAX_ASSERTED_EPOCH_LENGTH || + scopeHeader.length > MAX_ASSERTED_SCOPE_LENGTH || + scopeHeader.length === 0 + ) { + return { kind: "unparseable" }; + } + + // The bootstrap sentinel and every other non-digit value land here together, + // which is correct: both are "a fence-aware client that cannot yet prove its + // context", and both must reconcile rather than be served. + if (!/^\d+$/.test(epochHeader)) return { kind: "unparseable" }; + + const epoch = Number(epochHeader); + if (!Number.isSafeInteger(epoch) || epoch < 0) return { kind: "unparseable" }; + + return { + kind: "asserted", + epoch, + scope: scopeHeader === RECORD_SCOPE_SELF ? null : scopeHeader, + }; +} + +/** + * The verdict itself, as a pure function of the transport, the session row and + * what the request asserted. + * + * Pure on purpose, and for the reason `decideActingCarrier` is pure: it is the + * one statement of the rule, and a rule that needs a request context to be read + * is a rule that gets re-implemented somewhere it can drift. + */ +export function recordSessionFenceVerdict(input: { + transport: "cookie" | "bearer"; + /** The session row's counter. Sessions have none on the Bearer transport. */ + sessionEpoch: number; + /** The account the session is pointed at, or null for its own record. */ + sessionScope: string | null; + asserted: AssertedRecordContext; +}): RecordFenceVerdict { + // Bearer carries its selector per request and accumulates no switch state, so + // there is no context for a token to be stale about. The frozen native + // contract is untouched: no header is read, required, or honoured here. + if (input.transport === "bearer") return "pass"; + + // The exemption, written as a conjunction even though migration 0302 makes + // the second clause redundant (it normalised every already-pointed row to 1, + // and the trigger keeps it there). A redundant clause over an authorization + // boundary is the right direction to be wrong in, and the redundancy is + // asserted by the unit test rather than assumed. + // + // What it buys: every browser tab open at deploy time that was never inside + // a shared record keeps working with no header at all, byte for byte. What it + // costs: a session at epoch 0 is served even if it asserts something odd — + // which cannot arise, because a client's adopted epoch comes from this very + // session and a session at 0 has never moved, and which in any case fails + // open onto the caller's OWN record. + if (input.sessionEpoch === 0 && input.sessionScope === null) return "pass"; + + // From here the session HAS been inside somebody's record, now or before, and + // stays fenced in both directions permanently. A stale tab believing it is + // inside the owner's record while the session is back on its own is the same + // defect as the reverse. + if (input.asserted.kind === "absent") return "unfenced-client"; + if (input.asserted.kind === "unparseable") return "stale"; + if (input.asserted.epoch !== input.sessionEpoch) return "stale"; + // Belt and braces, deliberately: the epoch alone distinguishes transitions, + // and the scope additionally catches a selector that moved to a value the + // client did not expect. On the referential-action path both move at once. + if (input.asserted.scope !== input.sessionScope) return "stale"; + + return "pass"; +} + +/** The scope header value for a canonical scope. The inverse of the parse above. */ +export function recordScopeHeaderValue(scope: string | null): string { + return scope === null ? RECORD_SCOPE_SELF : scope; +} diff --git a/src/lib/sharing/record-session-fence.ts b/src/lib/sharing/record-session-fence.ts new file mode 100644 index 000000000..9b5dabfc4 --- /dev/null +++ b/src/lib/sharing/record-session-fence.ts @@ -0,0 +1,134 @@ +/** + * v1.37.0 — the server half of the record-session fence. + * + * The rule itself lives next door in `record-session-fence-contract.ts`, which + * imports nothing so the browser bundle can name the same headers. This file is + * the part that cannot be in a browser: it reads `next/headers`, it holds the + * refusals, and it stamps the wide event that becomes the response echo. + * + * Three things happen on every call, and the order matters: + * + * 1. Read the two asserted headers. + * 2. Stamp the context the request is ACTUALLY being served under onto the + * request-scoped wide event — on every call, pass or refuse. `apiHandler` + * turns that stamp into the response echo, and this is the only thing that + * ever stamps it. The alternative that looks equivalent is not: the wide + * event's `setActingAs` runs in exactly one place, inside + * `resolveSwitchedRecord`, so it never fires on the carrier-none path, on + * an actor surface, or on a public route — and a client rule of "2xx with + * no echo means discard" would then throw away every actor and public + * response, including the `/api/version` poll that runs on a timer. + * 3. Refuse, or return. + * + * ─── The atomicity property, stated once ────────────────────────────────── + * + * The epoch compared here and the `actingAsUserId` the resolver is about to + * serve under come from ONE `getSession()` row read — `findSessionByCookie` + * loads the whole row and both fields are projected off it. So "the scope the + * handler serves under" and "the epoch the fence validated" are the same fact + * read at the same instant, not two reads a switch can land between. That turns + * what would be a race window into a tautology, and it is why this function + * takes an `AuthContext` instead of resolving a session of its own. Do not add + * a second session read here for any reason. + * + * What that does NOT close, and nothing can: a request that has already passed + * the fence when a switch commits still completes under the context it was + * admitted with. Holding a lock across the handler's I/O would close it and was + * examined and rejected during v1.37.0 planning — it buys a disclosure window + * measured in milliseconds at the price of lock, pool, timeout and revocation + * availability defects. What covers it instead is the response echo: the client + * discards a response whose echoed context contradicts the one it is now on. + */ +import { headers } from "next/headers"; + +import { + RECORD_EPOCH_HEADER, + RECORD_SCOPE_HEADER, + parseAssertedContext, + recordSessionFenceVerdict, + type AssertedRecordContext, +} from "@/lib/sharing/record-session-fence-contract"; +import { + RecordSessionChangedError, + SharingAccessDeniedError, + type AuthContext, +} from "@/lib/api-handler"; +import { annotate, getEvent } from "@/lib/logging/context"; + +/** + * What this request asserted about its record context. + * + * `try`/`catch`-wrapped the way `readSelectorHeader` is: outside a Next.js + * request scope there is no header to read, and a direct unit invocation of a + * legacy route must not crash on the attempt. An unreadable header store is + * indistinguishable from a request that sent nothing, which is the honest + * answer — and on a fenced session that answer is refused, not served. + */ +export async function readAssertedRecordContext(): Promise { + try { + const headerList = await headers(); + return parseAssertedContext( + headerList.get(RECORD_EPOCH_HEADER), + headerList.get(RECORD_SCOPE_HEADER), + ); + } catch { + return { kind: "absent" }; + } +} + +/** + * Refuse this request if the record context it asserted is not the one its + * session is on. + * + * Called by `requireRecordAuth` and `requireGuardianAuth` immediately after + * `authenticateCaller()` and before `readActingCarrier()`, and independently by + * `withIdempotency` above the replay cache. Two independent evaluations against + * the same client assertion, with nothing carried between them: carrying a + * verdict across the wrapper/handler boundary would need request-scoped mutable + * state, which is a second source of truth for the same question. A switch + * landing between the two evaluations fails the second, so no handler body runs + * under a scope the asserted epoch does not imply. + */ +export async function assertRecordSessionFence( + auth: AuthContext, +): Promise { + const transport = auth.authMethod === "bearer" ? "bearer" : "cookie"; + const sessionEpoch = auth.session.recordEpoch ?? 0; + const sessionScope = auth.session.actingAsUserId ?? null; + + // Stamped before the verdict, so a refusal echoes the truth too. The Bearer + // transport carries no session context to echo and gets none: the frozen + // native contract sends no fence header and receives none back. + if (transport === "cookie") { + getEvent()?.setRecordContext({ epoch: sessionEpoch, scope: sessionScope }); + } + + const asserted = await readAssertedRecordContext(); + const verdict = recordSessionFenceVerdict({ + transport, + sessionEpoch, + sessionScope, + asserted, + }); + + if (verdict === "pass") return; + + if (verdict === "stale") { + annotate({ meta: { sharing_refusal: "record_context_stale" } }); + throw new RecordSessionChangedError(); + } + + // `unfenced-client`: a bundle that predates the fence, inside or after a + // shared-record context. It gets the code it already recovers from — see the + // two-refusals note in the contract module. + // + // No audit row, and that follows the rule already written for the session + // carrier in `api-handler.ts`: a HEADER refusal is a per-request assertion by + // a client and earns a durable record, a SESSION-state refusal is ambient + // browser state and would bury the rows that matter under noise from the + // feature working as designed. This arm is the second kind — every + // navigation, prefetch and poll from one stale tab lands here until it + // reloads, and the reload is the designed outcome. + annotate({ meta: { sharing_refusal: "unfenced_client" } }); + throw new SharingAccessDeniedError(); +}