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

+60 -1
View File
@@ -24,8 +24,27 @@ import type { Prisma } from "@/generated/prisma/client";
/**
* The narrow client slice these functions need, so a caller can hand them a
* transaction handle. Same shape as `grants.ts` uses for `accountGrant`.
*
* v1.37.0 — widened by one member. The conditional switch below needs
* `RETURNING`, which no model delegate can express, so it goes through
* `$queryRaw`. Widening the slice is what keeps the raw statement in THIS file:
* the alternative is a caller that names `record_epoch` itself, and "only
* `acting-session.ts` writes the selector" is the property the boundary guard
* in `src/__tests__/acting-account-boundary-guard.test.ts` exists to hold. Both
* existing call sites already pass `prisma` or a transaction handle, and both
* satisfy the wider shape unchanged.
*/
type SessionDb = Pick<Prisma.TransactionClient, "session">;
type SessionDb = Pick<Prisma.TransactionClient, "session" | "$queryRaw">;
/**
* The outcome of a conditional switch.
*
* `stale` is not a failure of the write; it is another tab having got there
* first. The caller turns it into the same 409 the fence raises, and the
* browser reconciles through `/api/auth/me` rather than retrying blind.
*/
export type PointSessionOutcome =
{ kind: "applied"; epoch: number } | { kind: "stale" };
/**
* Point one browser session at an account's record, or (with `null`) back at
@@ -48,6 +67,46 @@ export async function pointSessionAt(
});
}
/**
* v1.37.0 — the same write, conditional on the epoch the caller believes the
* session is at.
*
* One statement, and that is the whole design. Two tabs pressing the switcher
* at the same moment both name the epoch they saw; exactly one `UPDATE` matches
* the `WHERE`, and the loser gets zero rows back. That is the monotonic
* ordering guarantee, expressed as a compare-and-set rather than as a
* server-side pending lease with an expiry and an intermediate state that a
* crashed initiator can strand.
*
* `RETURNING "record_epoch"` reads the value the TRIGGER assigned, in the same
* statement that assigned it. So the epoch handed back to the client is the one
* the database committed and not a number this file computed — which matters,
* because a client that adopts a computed epoch would then assert a context the
* row does not hold and refuse itself on its very next request.
*
* Parameter-bound throughout: `$queryRaw` as a tagged template, no
* interpolation, no `Unsafe` variant.
*/
export async function pointSessionAtIfUnchanged(
sessionId: string,
userId: string,
actingAsUserId: string | null,
expectedEpoch: number,
db: SessionDb = prisma,
): Promise<PointSessionOutcome> {
const rows = await db.$queryRaw<{ record_epoch: number }[]>`
UPDATE "sessions"
SET "acting_as_user_id" = ${actingAsUserId}
WHERE "id" = ${sessionId}
AND "user_id" = ${userId}
AND "record_epoch" = ${expectedEpoch}
RETURNING "record_epoch"
`;
const applied = rows[0];
if (!applied) return { kind: "stale" };
return { kind: "applied", epoch: Number(applied.record_epoch) };
}
/**
* Drop every session of `granteeId` that is currently acting as `grantorId`.
*
+51
View File
@@ -45,6 +45,7 @@ import {
RECORD_EPOCH_HEADER,
RECORD_SCOPE_HEADER,
parseAssertedContext,
recordScopeHeaderValue,
recordSessionFenceVerdict,
type AssertedRecordContext,
} from "@/lib/sharing/record-session-fence-contract";
@@ -76,6 +77,56 @@ export async function readAssertedRecordContext(): Promise<AssertedRecordContext
}
}
/**
* Copy the context this response was served under onto its headers.
*
* Called once, from `apiHandler`, with whatever the fence stamped on the wide
* event — which is `undefined` on every response that never resolved a record
* scope. So the echo appears on exactly the responses where it means something,
* and its ABSENCE means "nothing about a record was decided here" rather than
* "unknown". That is what lets the browser's rule be discard-on-mismatch rather
* than discard-on-absence: a discard-on-absence client would throw away the
* public `/api/version` poll, the switcher list, admin, `/me` and every static
* response.
*
* It lives here, not in `apiHandler`, so the two header names stay inside the
* fence: declared in the contract module, read here, attached by the browser's
* `src/lib/api/record-fence.ts`, and published by the OpenAPI route module. A
* fifth file naming them is a route deciding something the fence decides once.
*/
export function attachRecordContextEcho(
headers: Headers,
context: { epoch: number; scope: string | null } | undefined,
): void {
if (!context) return;
headers.set(RECORD_EPOCH_HEADER, String(context.epoch));
headers.set(RECORD_SCOPE_HEADER, recordScopeHeaderValue(context.scope));
}
/**
* The record context to publish in the account payload, for a client that has
* none yet.
*
* `GET /api/auth/me` is the fence's bootstrap: a browser cannot assert a
* context until something tells it one, and this is that something. It lives
* here rather than in the route because the two columns behind it have one
* reader by design — a route projecting them itself would be a second statement
* of what a record context is, and the second copy is the one that drifts.
*
* Null on the Bearer transport, which has no session row and no switch state to
* be stale about. The native client neither sends nor receives fence headers,
* and this field tells it so explicitly rather than by omission.
*/
export function recordSessionForPayload(
auth: AuthContext,
): { epoch: number; scope: string | null } | null {
if (auth.authMethod !== "cookie") return null;
return {
epoch: auth.session.recordEpoch ?? 0,
scope: auth.session.actingAsUserId ?? null,
};
}
/**
* Refuse this request if the record context it asserted is not the one its
* session is on.