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

+77 -5
View File
@@ -21,11 +21,37 @@
* authorises nothing, and the resolver re-checks the grant on every request
* that follows. A value stranded here by a revocation seconds later confers
* exactly nothing.
*
* v1.37.0 — unfenced, deliberately, and the reason is the same one that makes
* it an actor surface: this is the way back. A session whose context cannot be
* proved must still be able to leave the record it may or may not be in, and
* fencing the exit would make an unprovable context unrecoverable.
*
* What it takes instead is an optional `expectedEpoch`, which turns the
* selector write into a compare-and-set. Two tabs pressing the switcher at once
* both name the epoch they saw; one `UPDATE` matches, the loser gets zero rows
* and the same 409 the fence raises, and the browser reconciles through
* `/api/auth/me` before retrying. That is the monotonic ordering a server-side
* pending lease was proposed for, in one statement, with no intermediate state
* for a crashed initiator to strand.
*
* An ABSENT `expectedEpoch` keeps the unconditional write, and the two halves
* of what that means are worth stating together:
*
* * It is reachable only by a bundle that predates the fence. The switcher is
* rendered from `/api/auth/me` data, so a fence-aware client has adopted a
* context before it can offer a switch at all — it never omits the field.
* * It can therefore clobber a concurrent fenced switch. That is accepted:
* the fenced tab's next request asserts an epoch the row no longer holds,
* it takes the 409, and it reconciles to the truth; the pre-fence tab
* reloads into a fence-aware bundle immediately afterwards. Fail-closed and
* self-healing, bounded to the deploy window.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
RecordSessionChangedError,
requireActorAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
@@ -39,7 +65,10 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { pointSessionAt } from "@/lib/sharing/acting-session";
import {
pointSessionAt,
pointSessionAtIfUnchanged,
} from "@/lib/sharing/acting-session";
import { findActiveGrant } from "@/lib/sharing/grants";
import { switchAccountSchema } from "@/lib/validations/account-sharing";
@@ -66,18 +95,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { accountId } = parsed.data;
const { accountId, expectedEpoch } = parsed.data;
const ip = getClientIp(request);
/**
* Move the selector, conditionally when the caller named an epoch.
*
* Returns the record context the session ends up in, so the response can
* publish it — this endpoint and `/api/auth/me` are the only two responses a
* client may ADOPT a context from, and both carry it in the body. Every other
* response's echo is used to validate and never to adopt.
*
* `null` on the unconditional arm, and that is the honest answer rather than
* a gap: the epoch after an unconditional write is whatever the trigger
* decided, this statement did not read it back, and a number computed here
* would be wrong the moment the target equalled the current selector (the
* trigger's `IS DISTINCT FROM` clause declines to move on a no-op write). A
* caller that omitted `expectedEpoch` is by construction a bundle that would
* not read the field anyway, and `/api/auth/me` states the truth on its next
* boot.
*/
async function moveSelector(
target: string | null,
): Promise<{ epoch: number; scope: string | null } | null | "stale"> {
if (expectedEpoch === undefined) {
await pointSessionAt(auth.session.id, auth.user.id, target);
return null;
}
const outcome = await pointSessionAtIfUnchanged(
auth.session.id,
auth.user.id,
target,
expectedEpoch,
);
if (outcome.kind === "stale") return "stale";
return { epoch: outcome.epoch, scope: target };
}
if (accountId === null) {
await pointSessionAt(auth.session.id, auth.user.id, null);
const moved = await moveSelector(null);
if (moved === "stale") {
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
throw new RecordSessionChangedError();
}
await auditLog("sharing.switch.off", {
userId: auth.user.id,
details: {},
ipAddress: ip,
}).catch(() => {});
annotate({ action: { name: "sharing.switch.off" } });
return apiSuccess({ actingAs: null });
return apiSuccess({ actingAs: null, recordSession: moved });
}
// The same predicate the resolver uses, on the same table, with no second
@@ -105,7 +172,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
select: { id: true, username: true, displayName: true },
});
await pointSessionAt(auth.session.id, auth.user.id, owner.id);
const moved = await moveSelector(owner.id);
if (moved === "stale") {
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
throw new RecordSessionChangedError();
}
await auditLog("sharing.switch.on", {
userId: auth.user.id,
@@ -125,5 +196,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
displayName: owner.displayName,
access: grant.access,
},
recordSession: moved,
});
});
+19
View File
@@ -31,6 +31,7 @@ import {
} from "@/lib/modules/gate";
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
import { resolveAccountAccess } from "@/lib/sharing/account-access";
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
export const dynamic = "force-dynamic";
@@ -173,5 +174,23 @@ export const GET = apiHandler(async () => {
// directly. Always present — an account with no grants gets an empty list,
// not a missing field.
accountAccess,
// v1.37.0 — the record-session fence's bootstrap, and the only place a
// browser can learn its context from scratch.
//
// `epoch` is the session row's selector counter; `scope` is the account the
// session is pointed at, or null for its own record. The client adopts both
// and attaches them to every subsequent same-origin request, so the server
// can refuse a request formed under a context that has since moved. This
// response and `POST /api/account/switch` are the only two a client may
// ADOPT from — every other response carries the context in headers, and
// those are used to validate a response, never to learn one.
//
// Null on the Bearer transport, which has no session row and no switch
// state to be stale about. A native client neither sends nor receives the
// fence headers; the frozen contract is untouched.
// Derived by the fence rather than read off the context here: the columns
// behind it have one reader by design, and a route that projected them
// itself would be a second statement of what a record context is.
recordSession: recordSessionForPayload(auth),
});
});