mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user