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

+46 -1
View File
@@ -38,7 +38,10 @@ import {
} 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";
import {
assertRecordSessionFence,
attachRecordContextEcho,
} from "./sharing/record-session-fence";
/**
* HTTP methods a read-only credential may use on the REST surface. A request
@@ -400,6 +403,28 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
}
const nr = response as NextResponse;
nr.headers.set("x-request-id", evt.getRequestId());
// v1.37.0 — echo the record context this response was actually served
// under, when one was decided. The value comes from the wide event and
// from nowhere else: the fence stamps it on every call it makes, so
// exactly the responses that resolved a record scope carry it and
// everything else — public routes, actor surfaces, admin, `/me`, static —
// carries nothing.
//
// Deliberately NOT derived here with a `getSession()`. That would put a
// session read on every public route, and it would make the echo a second
// derivation of the context rather than a report of the one that was
// used, which is the difference between "what this response served" and
// "what a later read thinks is true".
//
// Absence is therefore meaningful and safe: the client discards a
// response whose echo CONTRADICTS its adopted context, and serves one
// that carries no echo normally. A discard-on-absence rule would throw
// away the `/api/version` poll on every cycle.
//
// The two header names stay inside the fence module, which is what keeps
// them to four files overall (declared, read, attached client-side,
// published) — see `src/__tests__/record-session-fence-guard.test.ts`.
attachRecordContextEcho(nr.headers, evt.getRecordContext());
return nr;
});
};
@@ -832,6 +857,21 @@ export async function requireRecordAuth(
domain: ShareScope,
): Promise<RecordAuthContext> {
const auth = await authenticateCaller();
// v1.37.0 — before any carrier is read, any grant is looked up, or any record
// row is touched: does this request still believe what its session believes?
//
// The atomicity that makes this a tautology rather than a race: the epoch the
// fence compares and the `actingAsUserId` the carrier below is built from are
// BOTH projected off the one session row `authenticateCaller()` already
// loaded. The scope this handler serves under and the epoch it validated are
// therefore the same fact read at the same instant, not two reads a commit
// can land between. Do not add a second session read here to "refresh" it —
// that would reintroduce exactly the window this ordering removes.
//
// `authenticateCaller` necessarily runs first because it supplies the row the
// fence reads, so the invariant is "before the carrier", not "before the
// first database await".
await assertRecordSessionFence(auth);
const carrier = await readActingCarrier(auth);
if (carrier.kind === "none") {
@@ -903,6 +943,11 @@ function escalate(need: GrantNeed): GrantNeed {
*/
export async function requireGuardianAuth(): Promise<RecordAuthContext> {
const auth = await authenticateCaller();
// Same placement and the same reason as `requireRecordAuth` above: the
// guardian surfaces administer a managed profile, so a request arriving under
// a context that has since moved must be refused before the marker is read,
// not after.
await assertRecordSessionFence(auth);
const carrier = await readActingCarrier(auth);
if (carrier.kind === "none") {