Files
HealthLog/src/lib/api/record-fence.ts
T
Marc-André Bombeck 120323d995 test(02-12.1): prove Vary on the serving path, not only the contents
Removing the Vary block left the disk-layer case green: it asserted what
is IN the cache, and the claim is what the cache will SERVE. On the
external-switch path nothing wipes those entries — the app never
initiated the switch — so Vary is the only thing between a cached
delegated response and the next request under another context.

- assert cache.match() hits under the stored context and misses under
  the new one; it fails when the Vary is removed
- correct the acceptance map and the client docblock to claim exactly
  what executes: no zero-entry count, no offline leg
2026-08-06 23:04:29 +02:00

212 lines
8.8 KiB
TypeScript

/**
* v1.37.0 — the browser's half of the record-session fence.
*
* Every same-origin API request says which record it believes it is addressing,
* and every response that resolved a record scope says which one it was
* actually served under. This module holds both halves: the adopted context,
* the request headers built from it, and the response check.
*
* ─── Where the context comes from ─────────────────────────────────────────
*
* Exactly two responses may be ADOPTED from: `GET /api/auth/me` and
* `POST /api/account/switch`. Both are actor surfaces that resolve canonical
* session state, and both carry the context in the response BODY. Every other
* response's echoed context is used to VALIDATE and never to adopt — which is
* also why the 409 refusal carries only an error code and no epoch: a refusal
* arriving on a request whose context is in doubt is the worst possible third
* source of truth.
*
* ─── Why it is in memory only ─────────────────────────────────────────────
*
* Not mirrored to storage. `/api/auth/me` runs on every boot and is the single
* bootstrap, so a storage mirror would be a second answer that can go stale
* across a deploy — and the `bootstrap` sentinel already makes the
* pre-adoption window safe by producing a 409 rather than a served request.
*
* ─── The validation rule is MISMATCH-ONLY ─────────────────────────────────
*
* A response is discarded only when it carries an echo that CONTRADICTS the
* adopted context. A response carrying NO echo is returned untouched, because
* absence means "this response resolved no record scope" — which is exactly
* true for public routes, actor surfaces, admin, `/me` itself, and static
* responses.
*
* The concrete thing a discard-on-absence rule breaks:
* `src/components/version-poller.tsx` polls the PUBLIC `/api/version` through
* `apiGet` on a timer. That response resolves no record and echoes nothing, so
* a discard-on-absence client would enter the safe hold and fire a network-only
* `/me` on every poll cycle, forever. Do not "tighten" this rule.
*
* The case mismatch-only appears to give up — a service-worker disk-cache hit
* that predates the fence and therefore carries no echo — is closed by two
* other things, and it is worth being exact about which one does what:
*
* * `public/sw.js` names its caches by the app release and its `activate`
* handler deletes every `healthlog-(static|pages|data)-*` cache not in the
* current set, so the deploy that ships the fence evicts every pre-fence
* cached response before one can be served. Executed in
* `e2e/v137-record-session-fence-offline.spec.ts`;
* * a POST-fence cached record response is stored under a `Vary` on the two
* fence headers (`attachRecordContextEcho`), so `cache.match()` will not
* hand it to a request asserting a different context — or none. That is
* what covers the EXTERNAL switch, where this browser never ran its own
* cache wipe and the previous record's entries are still on disk. Also
* executed in that spec, and it fails when the `Vary` is removed.
*
* The client-side discard below is the third layer, over responses that reach
* it. It is not what protects the disk.
*/
import {
RECORD_EPOCH_HEADER,
RECORD_FENCE_BOOTSTRAP,
RECORD_SCOPE_HEADER,
recordScopeHeaderValue,
} from "@/lib/sharing/record-session-fence-contract";
/**
* The context this browser has adopted, or null before `/api/auth/me` has told
* it one.
*
* Imported from the contract module and NOT from `record-session-fence.ts` or
* `acting-carrier.ts`: those pull `next/headers` and Prisma, which typechecks
* cleanly in a client module and fails `pnpm build`.
*/
export interface AdoptedRecordContext {
epoch: number;
scope: string | null;
}
let adopted: AdoptedRecordContext | null = null;
/** What a mismatching response should trigger. Installed by the app shell. */
let onContextMismatch: (() => void) | null = null;
/**
* Adopt the context published by `GET /api/auth/me` or
* `POST /api/account/switch`.
*
* `null` is a real value with a real meaning — the Bearer transport, or a
* server image that predates the field — and it un-adopts, returning the client
* to the `bootstrap` sentinel rather than leaving a stale context in place.
*/
export function adoptRecordFenceState(
context: AdoptedRecordContext | null | undefined,
): void {
adopted = context ?? null;
}
/** What this browser currently believes. Read by the capability gate. */
export function currentRecordFenceState(): AdoptedRecordContext | null {
return adopted;
}
/**
* Install the handler a contradicting response triggers.
*
* A callback rather than a direct import of the transition store, so this
* module stays a transport concern and the app shell owns what "hold and
* reconcile" means. Returns a disposer.
*/
export function onRecordFenceMismatch(handler: () => void): () => void {
onContextMismatch = handler;
return () => {
if (onContextMismatch === handler) onContextMismatch = null;
};
}
/** @internal — deterministic module state for unit tests. */
export function __resetRecordFenceForTests(): void {
adopted = null;
onContextMismatch = null;
}
/**
* The two header values to send on this request.
*
* Before adoption both are the literal `bootstrap`. That is a PRESENT header,
* which is the whole point: the server's header-absent arm means exactly "a
* bundle that predates the fence" and answers it with the 403 such a bundle
* recovers from, so a fence-aware client must never reach it. On a session that
* never entered a shared record the server's exemption serves `bootstrap`
* normally, so an ordinary sign-in sees no refusal during boot.
*/
export function currentAssertion(): { epoch: string; scope: string } {
if (adopted === null) {
return { epoch: RECORD_FENCE_BOOTSTRAP, scope: RECORD_FENCE_BOOTSTRAP };
}
return {
epoch: String(adopted.epoch),
scope: recordScopeHeaderValue(adopted.scope),
};
}
/**
* Attach the assertion to a request.
*
* Applied inside `apiFetch` / `apiFetchEnvelope` / `apiFetchRaw` rather than at
* call sites, so no caller has to classify its own request as record traffic —
* a classification that would be wrong the first time a route changed mode. A
* caller that set the headers itself keeps them: an explicit value wins, the
* same posture `withDefaultTimeout` takes for `signal`.
*/
export function withRecordFenceHeaders(init?: RequestInit): RequestInit {
const headers = new Headers(init?.headers);
const assertion = currentAssertion();
if (!headers.has(RECORD_EPOCH_HEADER)) {
headers.set(RECORD_EPOCH_HEADER, assertion.epoch);
}
if (!headers.has(RECORD_SCOPE_HEADER)) {
headers.set(RECORD_SCOPE_HEADER, assertion.scope);
}
return { ...init, headers };
}
/**
* Discard a response served under a record context this browser is no longer
* on.
*
* Returns the response untouched when it carries no echo, or when the echo
* agrees. Throws when it contradicts — and triggers the safe hold on the way
* out, so the caller's error path and the browser's reconciliation are one
* event rather than two that can disagree.
*
* Read from headers only. Never consume the body here: `apiFetchRaw` hands the
* response to SSE readers and blob downloads that need the stream intact.
*/
export function validateResponseContext(res: Response): Response {
const echoedEpoch = res.headers.get(RECORD_EPOCH_HEADER);
const echoedScope = res.headers.get(RECORD_SCOPE_HEADER);
// No echo: this response resolved no record scope. True, safe, and the
// common case — see the version-poller note above.
if (echoedEpoch === null || echoedScope === null) return res;
// Not adopted yet: there is nothing to contradict. The request itself
// asserted `bootstrap`, so the server already decided whether to serve it.
if (adopted === null) return res;
const expected = currentAssertion();
if (echoedEpoch === expected.epoch && echoedScope === expected.scope) {
return res;
}
onContextMismatch?.();
throw new RecordContextMismatchError();
}
/**
* A response arrived describing a record this browser has left.
*
* Deliberately not an `ApiError`: nothing about the HTTP exchange failed, and a
* caller branching on `err.status` must not see a status that was never
* refused. What happened is that the answer is about somebody else's record,
* and the only safe thing to do with it is to drop it.
*/
export class RecordContextMismatchError extends Error {
constructor() {
super("Response describes a record this session has left");
this.name = "RecordContextMismatchError";
}
}