mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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
212 lines
8.8 KiB
TypeScript
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";
|
|
}
|
|
}
|