mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +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
+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") {
|
||||
|
||||
Reference in new issue
Block a user