mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
- 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
134 lines
5.6 KiB
TypeScript
134 lines
5.6 KiB
TypeScript
/**
|
|
* v1.36.0 — the only writer of `Session.actingAsUserId`.
|
|
*
|
|
* The column is the cookie transport's acting-account carrier. Two things
|
|
* write it and they are opposite acts: the switch endpoint points a browser
|
|
* session at a record, and the end of a grant points every affected session
|
|
* back at its own. Both live here rather than at their call sites so the
|
|
* carrier has one writer, the same way it has one reader (the resolver in
|
|
* `src/lib/api-handler.ts`). The structural guard in
|
|
* `src/__tests__/acting-account-boundary-guard.test.ts` holds that shape: any
|
|
* other file that so much as names the column fails it.
|
|
*
|
|
* Nothing here authorises anything. `pointSessionAt` writes an id its caller
|
|
* has already proven, and the resolver re-checks the grant on every request
|
|
* regardless — a value stranded here by a grant that has since ended confers
|
|
* exactly nothing. The clearing below is therefore housekeeping, not
|
|
* enforcement: it exists so a delegate's browser lands back in its own account
|
|
* instead of on a wall of 403s. Enforcement already happened when the grant
|
|
* row was stamped.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
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" | "$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
|
|
* its own.
|
|
*
|
|
* Scoped by session id AND the caller's own user id. The id alone would be
|
|
* enough in every code path that exists today; pinning the owner as well means
|
|
* a future caller that gets a session id from somewhere less trustworthy than
|
|
* `requireActorAuth` cannot use this to steer somebody else's browser.
|
|
*/
|
|
export async function pointSessionAt(
|
|
sessionId: string,
|
|
userId: string,
|
|
actingAsUserId: string | null,
|
|
db: SessionDb = prisma,
|
|
): Promise<void> {
|
|
await db.session.updateMany({
|
|
where: { id: sessionId, userId },
|
|
data: { actingAsUserId },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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`.
|
|
*
|
|
* Called inside the transaction that ends a grant, which is the point: the
|
|
* grant row and the sessions pointing at it stop being true in one atomic
|
|
* step. Split across two statements, a revocation that failed halfway would
|
|
* leave a browser sitting inside a record it no longer has access to — every
|
|
* request refused, the banner still claiming otherwise, and nothing in the UI
|
|
* able to explain it.
|
|
*
|
|
* Returns the number of sessions cleared, so the caller can put it on the wide
|
|
* event: "revoked, and dropped her out of it" is a different fact from
|
|
* "revoked", and the owner pressing the button deserves to know which happened.
|
|
*/
|
|
export async function clearActingSessions(
|
|
pair: { grantorId: string; granteeId: string },
|
|
db: SessionDb = prisma,
|
|
): Promise<number> {
|
|
const { count } = await db.session.updateMany({
|
|
where: { userId: pair.granteeId, actingAsUserId: pair.grantorId },
|
|
data: { actingAsUserId: null },
|
|
});
|
|
return count;
|
|
}
|