feat(02-12.1): refuse a record request whose context has moved

- both record resolvers check the fence after authentication and before
  the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
  conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 18:21:40 +02:00
1 parent 59359738c8
commit 45fec0fc1b
15 files changed
+1631 -98

No files matched your search

@@ -0,0 +1,267 @@
/**
* Structural guards on the record-session fence.
*
* The behavioural proofs live in `src/lib/sharing/__tests__/record-session-fence.test.ts`
* (the verdict and the refusals), `tests/integration/record-session-fence.test.ts`
* (a refusal before any database work, with a positive control) and
* `tests/integration/record-session-fence-idempotency.test.ts` (a refusal above
* the replay cache). Those prove what today's code does. This file freezes what
* tomorrow's may not do, because the fence has one shape that makes it worth
* anything:
*
* * exactly two auth helpers consult it, and both consult it BEFORE they read
* a carrier or look up a grant;
* * the helpers that deliberately do not consult it still do not;
* * one file writes the epoch and a closed list reads it;
* * the header names live in four files and no fifth.
*
* ## What these matchers cannot see
*
* Everything here is a text match over source, so the guard's own limit is
* worth writing down the way `session-surface-guard.test.ts` writes down its
* own: **a fence bypass under a different header name would slip every matcher
* below.** A future module that read `x-healthlog-record-context` and made its
* own admission decision would be invisible here, exactly as the share-link
* tree was invisible to a sweep that grepped only for `getSession`. The header
* leg pins the names that exist; it cannot pin the ones that do not yet.
*
* Nor can it prove ordering at runtime. The placement legs compare source
* offsets inside a function body, which is a proxy for execution order and
* holds only because neither call sits inside a branch. The runtime property —
* "no grant was looked up" — is proved by the integration file's `lastUsedAt`
* and `sharing.record.accessed` instruments and by the `findActiveGrant`
* zero-call spy, not here.
*
* Every leg asserts a non-zero match count before it asserts a set, because a
* guard whose matcher finds nothing reports success, and this repository has
* shipped several of those.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function filesMatching(re: RegExp): string[] {
return sourceFiles().filter((rel) => re.test(read(rel)));
}
/**
* The source text of a top-level exported function, braces included. Same
* brace-matching walk `acting-account-boundary-guard.test.ts` uses.
*/
function functionBody(src: string, name: string): string {
const start = src.indexOf(`export async function ${name}(`);
if (start === -1) return "";
const open = src.indexOf("{", src.indexOf(")", start));
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth -= 1;
if (depth === 0) return src.slice(open, i + 1);
}
}
return "";
}
const API_HANDLER = read("lib/api-handler.ts");
/** The two helpers that resolve a record and therefore must consult the fence. */
const FENCED_RESOLVERS = ["requireRecordAuth", "requireGuardianAuth"] as const;
/**
* The helpers that deliberately do NOT consult the fence.
*
* This is a statement about the HELPERS and must not be read as "no admin route
* is ever fenced". `POST /api/admin/backups/[id]/restore` composes
* `withIdempotency` OUTSIDE `apiHandler`, so it is fence-visible through the
* idempotency arm even though `requireAdmin` itself is on this list — and that
* is wanted, not an oversight. An idempotent admin write under an unprovable
* context should be refused like any other.
*
* Each entry's reason, once:
*
* * `requireActorAuth` is the way OUT of a switch. Fencing it would make an
* unprovable context unrecoverable, and it serves the caller's own rows by
* definition, so the selector cannot change what it returns.
* * `requireAuth` bare already refuses outright under an active switch, and
* its routes are classified actor-only elsewhere.
* * `requireAdmin`, `requireCookieAuth` and `requireFreshMfa` resolve through
* `getSession()` and are structurally unreachable by a switch.
*/
const UNFENCED_HELPERS = [
"requireAuth",
"requireActorAuth",
"requireAdmin",
"requireCookieAuth",
"requireFreshMfa",
] as const;
describe("the fence runs in exactly two auth helpers", () => {
it("is called by both record resolvers and by nothing else in api-handler", () => {
const calls = [...API_HANDLER.matchAll(/assertRecordSessionFence\s*\(/g)];
// Non-zero proof: an emptied file would agree with an emptied expectation.
expect(calls.length).toBeGreaterThan(0);
const callers = FENCED_RESOLVERS.filter((name) =>
functionBody(API_HANDLER, name).includes("assertRecordSessionFence("),
);
expect(callers).toEqual([...FENCED_RESOLVERS]);
// Two call sites, one import. Anything else means a third caller appeared.
expect(calls.length).toBe(FENCED_RESOLVERS.length);
});
it.each(FENCED_RESOLVERS)(
"%s fences after authentication and before the carrier and the grant",
(name) => {
const body = functionBody(API_HANDLER, name);
expect(body.length).toBeGreaterThan(100);
const fence = body.indexOf("assertRecordSessionFence(");
const authenticate = body.indexOf("authenticateCaller(");
const carrier = body.indexOf("readActingCarrier(");
expect(fence).toBeGreaterThan(-1);
expect(authenticate).toBeGreaterThan(-1);
expect(carrier).toBeGreaterThan(-1);
// `authenticateCaller` NECESSARILY precedes the fence: it supplies the
// session row the fence reads, and the whole atomicity argument rests on
// both facts coming off that one read. So the invariant is "after
// authentication, before the carrier" — NOT "before the first database
// await", which would be false and must not be asserted here.
expect(fence).toBeGreaterThan(authenticate);
expect(fence).toBeLessThan(carrier);
},
);
it("fences before any grant lookup in the shared resolver path", () => {
// `findActiveGrant` lives in `resolveSwitchedRecord`, which both resolvers
// tail-call. Assert it appears nowhere ABOVE the fence in either body.
const grantCalls = [...API_HANDLER.matchAll(/findActiveGrant\s*\(/g)];
expect(grantCalls.length).toBeGreaterThan(0);
for (const name of FENCED_RESOLVERS) {
const body = functionBody(API_HANDLER, name);
const fence = body.indexOf("assertRecordSessionFence(");
const grant = body.indexOf("findActiveGrant(");
// Either the body never names it (it is in the shared tail) or it names
// it after the fence. Both are the property; neither may invert.
if (grant !== -1) expect(fence).toBeLessThan(grant);
}
});
it.each(UNFENCED_HELPERS)("%s deliberately does not fence", (name) => {
const body = functionBody(API_HANDLER, name);
expect(body.length).toBeGreaterThan(50);
expect(body).not.toContain("assertRecordSessionFence");
});
it("the two unfenced routes stay unfenced", () => {
// `/api/auth/me` is the reconciliation bootstrap and must answer while the
// client's context is unknown; the switch endpoint is the way out of a
// context that cannot be proved. Fencing either would make an unprovable
// context unrecoverable.
const me = read("app/api/auth/me/route.ts");
const sw = read("app/api/account/switch/route.ts");
expect(me).toContain("requireActorAuth");
expect(sw).toContain("requireActorAuth");
expect(me).not.toContain("assertRecordSessionFence");
expect(sw).not.toContain("assertRecordSessionFence");
});
});
/**
* Both lists below grow as the feature lands, and that is the point rather than
* a defect: the idempotency arm and the browser's adopted-context module each
* arrive as a visible diff here rather than as a quiet new reader. Same posture
* as `CARRIER_ALLOWLIST` in `acting-account-boundary-guard.test.ts`.
*/
describe("the epoch has one writer and a closed reader set", () => {
it("is written in exactly one file", () => {
// The SQL spelling only. `acting-session.ts` owns the conditional write
// because it needs `RETURNING`, which no model delegate expresses — and
// keeping the statement there is what keeps this list at one.
const writers = filesMatching(/record_epoch/);
expect(writers.length).toBeGreaterThan(0);
expect(writers).toEqual(["lib/sharing/acting-session.ts"]);
});
it("is read by a closed list and nothing else", () => {
// The identifier, bounded so `recordEpochParameter` in the OpenAPI module
// does not count — that file publishes a header name, it does not read a
// column.
const readers = filesMatching(/\brecordEpoch(?![\w$])/);
expect(readers.length).toBeGreaterThan(0);
expect(readers).toEqual(
[
// Projects it off the session row it already loaded.
"lib/auth/session.ts",
// Carries it on the auth context the fence is handed.
"lib/api-handler.ts",
// Compares it. The one place that decides anything from it.
"lib/sharing/record-session-fence.ts",
].sort(),
);
});
it("does not leak the raw counter into the browser's vocabulary", () => {
// The client never names the epoch as a column. It receives
// `recordSession: { epoch, scope }` and asserts it back as a header, which
// is why no file under `hooks/` or `components/` appears in the list above.
// A client that started naming `recordEpoch` would be reaching past the
// published shape into the row.
const clientReaders = filesMatching(/\brecordEpoch(?![\w$])/).filter(
(rel) =>
rel.startsWith("hooks/") ||
rel.startsWith("components/") ||
rel.startsWith("app/"),
);
expect(clientReaders).toEqual([]);
});
});
describe("the header names live in a closed set of files", () => {
it("is declared, read and published — and nowhere else", () => {
// Mirrors the `x-healthlog-account` leg in
// `acting-account-boundary-guard.test.ts`. A route that started reading
// these headers itself would be making an admission decision the fence
// exists to make once.
const namers = filesMatching(
/RECORD_EPOCH_HEADER|RECORD_SCOPE_HEADER|x-healthlog-record-(epoch|scope)/i,
);
expect(namers.length).toBeGreaterThan(0);
expect(namers).toEqual([
// Publishes them to the clients that have to send them.
"lib/openapi/routes/account-sharing.ts",
// Declares them.
"lib/sharing/record-session-fence-contract.ts",
// Reads them off the request scope, and writes the response echo.
// `apiHandler` attaches the echo through this module's helper rather than
// naming the headers itself, which is what keeps this list at four.
"lib/sharing/record-session-fence.ts",
]);
});
it("keeps the 409's code in the contract module and the client that branches on it", () => {
const namers = filesMatching(/sharing\.session\.changed/);
expect(namers.length).toBeGreaterThan(0);
// The error class reads the constant rather than restating the string, so
// `api-handler.ts` is deliberately NOT on this list.
expect(namers).toContain("lib/sharing/record-session-fence-contract.ts");
});
});