mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
59359738c8
commit
45fec0fc1b
15 files changed
+1631
-98
No files matched your search
@@ -151,10 +151,16 @@ function user(
|
||||
/**
|
||||
* Sign the delegate in over the cookie transport.
|
||||
*
|
||||
* `recordEpoch` defaults to 0, which is the record-session fence's exemption:
|
||||
* a session that has never been pointed at another record is served without a
|
||||
* fence header, so every pre-fence case in this file keeps meaning what it
|
||||
* meant. Cases that want the fence live pass an epoch explicitly.
|
||||
* v1.37.0 — this also attaches a TRUTHFUL record-session assertion, because
|
||||
* that is what the shipped browser client does on every same-origin request.
|
||||
* Without it every switched case in this file would be testing the fence's
|
||||
* pre-fence-bundle arm rather than the resolver arm it was written for: a
|
||||
* session pointed at a record is fenced, and a request carrying no assertion at
|
||||
* all is refused with 403 before the resolver decides anything.
|
||||
*
|
||||
* `recordEpoch` defaults to 0. A session at 0 pointed at its own record is the
|
||||
* fence's exemption, so the un-switched cases are byte-for-byte what they were.
|
||||
* Cases that want a stale or absent assertion override the headers themselves.
|
||||
*/
|
||||
function signedInCookie(
|
||||
actingAsUserId: string | null = null,
|
||||
@@ -170,6 +176,8 @@ function signedInCookie(
|
||||
},
|
||||
user: user(DELEGATE, role) as never,
|
||||
});
|
||||
headerJar.set("x-healthlog-record-epoch", String(recordEpoch));
|
||||
headerJar.set("x-healthlog-record-scope", actingAsUserId ?? "self");
|
||||
}
|
||||
|
||||
/** Sign the delegate in over the Bearer transport. */
|
||||
@@ -906,3 +914,115 @@ describe("the guardian resolver gates on the marker, not the grant", () => {
|
||||
expect(fencedBody).not.toContain("managed");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the record-session fence, from the resolver's side.
|
||||
*
|
||||
* The integration file proves the refusal against a real database with the
|
||||
* `lastUsedAt` / `sharing.record.accessed` instrument. What this adds is the
|
||||
* cheapest falsifiable statement of the same property: the grant query is not
|
||||
* issued at all.
|
||||
*
|
||||
* The instrument is `prisma.accountGrant.findFirst`, not a mocked
|
||||
* `findActiveGrant`. This file deliberately does not mock `@/lib/sharing/grants`
|
||||
* — the resolver consuming that module's own idea of "active" is a property
|
||||
* under test here, and a mocked predicate would report it working while it was
|
||||
* broken. The Prisma spy is one layer lower, is honest about the same fact, and
|
||||
* costs nothing.
|
||||
*/
|
||||
describe("the record-session fence, above the grant lookup", () => {
|
||||
it("issues no grant query when the asserted epoch is behind", async () => {
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
headerJar.set("x-healthlog-record-epoch", "3");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
|
||||
const res = await route(() => requireRecordAuth("read", "measurements"))();
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta.errorCode).toBe("sharing.session.changed");
|
||||
// The whole point: nothing was asked of the grant table.
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
// And nothing was stamped on it either.
|
||||
expect(touched).toEqual([]);
|
||||
});
|
||||
|
||||
it("issues no grant query when the client sent no assertion at all", async () => {
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
// A bundle that predates the fence: no assertion on the wire at all.
|
||||
headerJar.delete("x-healthlog-record-epoch");
|
||||
headerJar.delete("x-healthlog-record-scope");
|
||||
|
||||
const res = await route(() => requireRecordAuth("read", "measurements"))();
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect((await res.json()).meta.errorCode).toBe("sharing.access.denied");
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("POSITIVE CONTROL: a matching assertion does reach the grant query", async () => {
|
||||
// Without this the test above would pass for a resolver that never queried
|
||||
// the grant table at all.
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
headerJar.set("x-healthlog-record-epoch", "4");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
|
||||
const ctx = await resolveInRoute(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
);
|
||||
|
||||
expect(ctx.user.id).toBe(OWNER);
|
||||
expect(prisma.accountGrant.findFirst).toHaveBeenCalled();
|
||||
expect(touched).toEqual(["grant-1"]);
|
||||
});
|
||||
|
||||
it("guards the guardian resolver the same way", async () => {
|
||||
signedInCookie(OWNER, "USER", 2);
|
||||
userRows.set(OWNER, user(OWNER, "USER", new Date()));
|
||||
liveGrant({ access: "MANAGE" });
|
||||
headerJar.set("x-healthlog-record-epoch", "1");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
|
||||
const res = await route(() => requireGuardianAuth())();
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a never-switched session alone, header or no header", async () => {
|
||||
// The exemption. A session at epoch 0 pointed at its own record is served
|
||||
// byte-for-byte as it was before the fence existed.
|
||||
signedInCookie(null, "USER", 0);
|
||||
headerJar.delete("x-healthlog-record-epoch");
|
||||
headerJar.delete("x-healthlog-record-scope");
|
||||
const ctx = await resolveInRoute(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
);
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("echoes the context it served under, and only when it resolved one", async () => {
|
||||
signedInCookie(OWNER, "USER", 4);
|
||||
liveGrant();
|
||||
headerJar.set("x-healthlog-record-epoch", "4");
|
||||
headerJar.set("x-healthlog-record-scope", OWNER);
|
||||
const served = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
expect(served.headers.get("x-healthlog-record-epoch")).toBe("4");
|
||||
expect(served.headers.get("x-healthlog-record-scope")).toBe(OWNER);
|
||||
|
||||
// An actor surface decides nothing about a record and therefore echoes
|
||||
// nothing. A client that discarded on ABSENCE would throw this away, and
|
||||
// with it the public `/api/version` poll.
|
||||
headerJar.clear();
|
||||
signedInCookie(null, "USER", 0);
|
||||
const actor = await route(() => requireActorAuth())();
|
||||
expect(actor.status).toBe(200);
|
||||
expect(actor.headers.get("x-healthlog-record-epoch")).toBeNull();
|
||||
expect(actor.headers.get("x-healthlog-record-scope")).toBeNull();
|
||||
});
|
||||
});
|
||||
+46
-1
@@ -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") {
|
||||
|
||||
@@ -118,8 +118,45 @@ const grantListResponse = z
|
||||
"Both directions at once: `given` are the grants this account has offered on its own record, `received` are the ones offered to it. Capped at 100 rows per direction, newest first.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The record-session context, as the two surfaces a client may ADOPT one from
|
||||
* publish it.
|
||||
*
|
||||
* Both carry it in the response BODY, and that is the rule rather than a
|
||||
* coincidence: every other response reports its context in headers, and those
|
||||
* are for validating a response the client already has, never for learning one.
|
||||
* Two body-carrying sources, both actor surfaces that resolve canonical session
|
||||
* state, and no third path — which is why the 409 refusal below carries only an
|
||||
* error code and never the current epoch.
|
||||
*/
|
||||
const recordSessionState = z
|
||||
.object({
|
||||
epoch: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request, and as `expectedEpoch` when switching.",
|
||||
),
|
||||
scope: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The account this session is pointed at, or null for its own record. Send it back as the `X-HealthLog-Record-Scope` header, with `self` standing for null.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "RecordSessionState",
|
||||
description:
|
||||
"Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which carries its selector per request and accumulates no session state to be stale about. A client adopts this and then asserts it on every same-origin request, so a request formed under a context that has since moved is refused rather than served against the wrong record.",
|
||||
});
|
||||
|
||||
const switchResponse = z
|
||||
.object({
|
||||
recordSession: recordSessionState
|
||||
.nullable()
|
||||
.describe(
|
||||
"The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the moment the target equalled the current selector. Adopt this value; do not derive it.",
|
||||
),
|
||||
actingAs: z
|
||||
.object({
|
||||
accountId: z.string(),
|
||||
@@ -182,6 +219,12 @@ const sharingRefusal = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const recordSessionChanged = {
|
||||
description:
|
||||
"The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a third source of truth.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
/**
|
||||
* The per-request account selector, published once.
|
||||
*
|
||||
@@ -209,6 +252,43 @@ export const accountSelectorParameter: NonNullable<
|
||||
"Act on another account's record for THIS request. Bearer transport only — the browser session carries its switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason.",
|
||||
};
|
||||
|
||||
/**
|
||||
* The record-session fence's two request headers, published once.
|
||||
*
|
||||
* Cookie transport only, and stated as such in both descriptions: a Bearer
|
||||
* client neither sends these nor receives them back, and the native contract is
|
||||
* unchanged by the fence in every direction. A browser client attaches both to
|
||||
* every same-origin request — there is no per-route opt-in, because a route
|
||||
* that had to remember is a route that will forget.
|
||||
*
|
||||
* Defined here rather than inline for the same reason the account selector
|
||||
* above is: `src/__tests__/record-session-fence-guard.test.ts` holds the header
|
||||
* names to exactly four files — the contract module that declares them, the
|
||||
* server fence that reads them, the client module that attaches them, and this
|
||||
* one, which publishes them.
|
||||
*/
|
||||
export const recordEpochParameter: NonNullable<
|
||||
NonNullable<ZodOpenApiObject["components"]>["parameters"]
|
||||
>[string] = {
|
||||
name: "X-HealthLog-Record-Epoch",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string", maxLength: 15 },
|
||||
description:
|
||||
"The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET /api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it. Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been pointed at another record is served without either header, which is what keeps a tab open across the deploy working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry), and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers by leaving the record and reloading).",
|
||||
};
|
||||
|
||||
export const recordScopeParameter: NonNullable<
|
||||
NonNullable<ZodOpenApiObject["components"]>["parameters"]
|
||||
>[string] = {
|
||||
name: "X-HealthLog-Record-Scope",
|
||||
in: "header",
|
||||
required: false,
|
||||
schema: { type: "string", maxLength: 64 },
|
||||
description:
|
||||
"The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self` standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are echoed back on any response that actually resolved a record scope, and a client discards a response whose echo contradicts the context it is now on — a response carrying no echo resolved no record scope and is served normally.",
|
||||
};
|
||||
|
||||
// ── What the client is told it may do ────────────────────────────────────────
|
||||
|
||||
export const accountAccessBlock = accountAccessBlockSchema.meta({
|
||||
@@ -231,6 +311,7 @@ const accountPayload = z
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
accountAccess: accountAccessBlock,
|
||||
recordSession: recordSessionState.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "AccountPayload",
|
||||
@@ -466,7 +547,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Act on a granted record, or return to your own",
|
||||
description:
|
||||
"Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out.",
|
||||
"Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409 `sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and retry once against the reconciled epoch.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: switchAccountSchema } },
|
||||
@@ -487,6 +568,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": sharingRefusal,
|
||||
"409": recordSessionChanged,
|
||||
"422": {
|
||||
description: "Validation failed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
|
||||
@@ -24,6 +24,8 @@ import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import {
|
||||
accountSelectorParameter,
|
||||
accountSharingPaths,
|
||||
recordEpochParameter,
|
||||
recordScopeParameter,
|
||||
} from "./account-sharing";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { allergyPaths } from "./allergies";
|
||||
@@ -156,7 +158,14 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
// module so that `src/__tests__/acting-account-boundary-guard.test.ts` keeps
|
||||
// holding the header's name to two files: the resolver that reads it, and
|
||||
// the one place that publishes it.
|
||||
parameters: { AccountSelector: accountSelectorParameter },
|
||||
// v1.37.0 — the record-session fence's two request headers, defined in the
|
||||
// same module for the same reason: the fence guard holds their names to four
|
||||
// files, and this index references rather than restates them.
|
||||
parameters: {
|
||||
AccountSelector: accountSelectorParameter,
|
||||
RecordEpoch: recordEpochParameter,
|
||||
RecordScope: recordScopeParameter,
|
||||
},
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
|
||||
@@ -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`.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -108,6 +108,28 @@ export type InviteGrantBody = z.infer<typeof inviteGrantSchema>;
|
||||
*/
|
||||
export const switchAccountSchema = z.object({
|
||||
accountId: z.string().min(1).max(MAX_ACCOUNT_ID_LENGTH).nullable(),
|
||||
/**
|
||||
* v1.37.0 — the record epoch the caller believes this session is at.
|
||||
*
|
||||
* Present turns the selector write into a compare-and-set: exactly one of two
|
||||
* tabs pressing the switcher at the same moment lands, and the loser gets a
|
||||
* 409 rather than silently overwriting the winner. It is not a credential —
|
||||
* the epoch is server-issued, monotonic, and scoped to the caller's own
|
||||
* session, so naming it can only ever refuse the caller's own write.
|
||||
*
|
||||
* Absent keeps the pre-fence unconditional write, and that arm 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 necessarily adopted a
|
||||
* context before it can offer a switch at all.
|
||||
*/
|
||||
expectedEpoch: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
"The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a previous switch. Present makes the write conditional: if another tab moved the selector first the switch is refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me. Omitted keeps the unconditional write, which is what a client predating the record-session fence sends. Cookie transport only.",
|
||||
),
|
||||
});
|
||||
|
||||
export type SwitchAccountBody = z.infer<typeof switchAccountSchema>;
|
||||
Reference in new issue
Block a user