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
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 17:59:07 +02:00
1 parent 5c9befc0ae
commit 59359738c8
7 files changed
+895

No files matched your search

@@ -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", () => {
+44
View File
@@ -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;
/**
+19
View File
@@ -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 ?? {}),
+12
View File
@@ -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)
@@ -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<string, string>();
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<typeof recordSessionFenceVerdict>[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();
});
});
@@ -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;
}
+134
View File
@@ -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<AssertedRecordContext> {
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<void> {
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();
}