mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
feat(02-12.1): carry and verify the record context from the browser
- attach the asserted context to every same-origin request inside the three fetch entries, so no call site classifies itself - discard a response whose echoed context contradicts the adopted one; serve one that carries no echo, which is what keeps the public poll and every actor surface working - adopt from /api/auth/me and the switch response, and from nothing else - send the epoch on every switch and retry once after reconciling - hold and reconcile on the 409 instead of leaving the record
This commit is contained in:
1 parent
3a99de312e
commit
31b4857d8b
15 files changed
+939
-25
No files matched your search
@@ -39,6 +39,18 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** The record-session assertion the transport attached to a `fetch` call. */
|
||||
function recordFenceHeadersOf(call: unknown[]): {
|
||||
epoch: string | null;
|
||||
scope: string | null;
|
||||
} {
|
||||
const headers = new Headers((call[1] as RequestInit | undefined)?.headers);
|
||||
return {
|
||||
epoch: headers.get("x-healthlog-record-epoch"),
|
||||
scope: headers.get("x-healthlog-record-scope"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("apiFetch", () => {
|
||||
it("unwraps the data payload from the envelope", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
@@ -48,8 +60,17 @@ describe("apiFetch", () => {
|
||||
const data = await apiFetch<{ id: string }>("/api/measurements/m1");
|
||||
|
||||
expect(data).toEqual({ id: "m1" });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/measurements/m1", {
|
||||
signal: expect.any(AbortSignal),
|
||||
// v1.37.0 — `headers` is now always present: the transport attaches the
|
||||
// record-session assertion to every same-origin request, so no call site
|
||||
// has to classify itself as record traffic. `objectContaining` rather than
|
||||
// an exact object so the assertion stays about what THIS test is for.
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/measurements/m1",
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(recordFenceHeadersOf(fetchMock.mock.calls[0])).toEqual({
|
||||
epoch: "bootstrap",
|
||||
scope: "bootstrap",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,10 +83,13 @@ describe("apiFetch", () => {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/version", {
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/version",
|
||||
expect.objectContaining({
|
||||
cache: "no-store",
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves undefined for a 204 response", async () => {
|
||||
@@ -242,10 +266,13 @@ describe("verb helpers", () => {
|
||||
|
||||
await apiGet("/api/foo");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method: "GET",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/foo",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -272,10 +299,17 @@ describe("verb helpers", () => {
|
||||
|
||||
await apiPost("/api/foo");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/foo", {
|
||||
method: "POST",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/foo",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
);
|
||||
// Still no body and no Content-Type — the fence headers are the only ones.
|
||||
const [, bodylessInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(bodylessInit.body).toBeUndefined();
|
||||
expect(new Headers(bodylessInit.headers).get("Content-Type")).toBeNull();
|
||||
});
|
||||
|
||||
it("merges caller headers with the JSON Content-Type", async () => {
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* The browser's half of the record-session fence: what it asserts on a request,
|
||||
* and what it does with the context a response echoes back.
|
||||
*
|
||||
* The two halves of the response rule are worth stating as opposites, because
|
||||
* getting either backwards is a live defect:
|
||||
*
|
||||
* * a MISMATCHING echo is discarded — the answer is about a record this
|
||||
* browser has left, and serving it paints one person's numbers under
|
||||
* another person's name;
|
||||
* * an ABSENT echo is served normally — it means the response resolved no
|
||||
* record scope, which is true of every public route, actor surface, admin
|
||||
* surface and static response. `/api/version` is polled on a timer through
|
||||
* `apiGet`, so a discard-on-absence rule would put the browser into a
|
||||
* permanent hold-and-reconcile loop.
|
||||
*
|
||||
* Break it by making `validateResponseContext` discard on absence: the
|
||||
* `/api/version`-shaped case fails, and no hold is entered.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import {
|
||||
RECORD_EPOCH_HEADER,
|
||||
RECORD_FENCE_BOOTSTRAP,
|
||||
RECORD_SCOPE_HEADER,
|
||||
RECORD_SCOPE_SELF,
|
||||
} from "@/lib/sharing/record-session-fence-contract";
|
||||
import {
|
||||
RecordContextMismatchError,
|
||||
__resetRecordFenceForTests,
|
||||
adoptRecordFenceState,
|
||||
currentAssertion,
|
||||
currentRecordFenceState,
|
||||
onRecordFenceMismatch,
|
||||
validateResponseContext,
|
||||
withRecordFenceHeaders,
|
||||
} from "@/lib/api/record-fence";
|
||||
|
||||
const OWNER = "owner-account-id";
|
||||
|
||||
function respond(headers: Record<string, string> = {}): Response {
|
||||
return new Response(JSON.stringify({ data: null, error: null }), {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** What `/api/version` looks like on the wire: 2xx, no record echo. */
|
||||
function publicResponse(): Response {
|
||||
return respond({ "content-type": "application/json" });
|
||||
}
|
||||
|
||||
function echoing(epoch: number, scope: string | null): Response {
|
||||
return respond({
|
||||
[RECORD_EPOCH_HEADER]: String(epoch),
|
||||
[RECORD_SCOPE_HEADER]: scope ?? RECORD_SCOPE_SELF,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
__resetRecordFenceForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetRecordFenceForTests();
|
||||
});
|
||||
|
||||
describe("what the browser asserts on a request", () => {
|
||||
it("sends the bootstrap sentinel before anything has been adopted", () => {
|
||||
expect(currentAssertion()).toEqual({
|
||||
epoch: RECORD_FENCE_BOOTSTRAP,
|
||||
scope: RECORD_FENCE_BOOTSTRAP,
|
||||
});
|
||||
const init = withRecordFenceHeaders();
|
||||
const headers = new Headers(init.headers);
|
||||
// Present, not absent. Header-absent means exactly one thing on the wire —
|
||||
// a bundle that predates the fence — and a fence-aware client must never
|
||||
// be mistaken for one.
|
||||
expect(headers.get(RECORD_EPOCH_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP);
|
||||
expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_FENCE_BOOTSTRAP);
|
||||
});
|
||||
|
||||
it("sends the adopted context once /me has answered", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const headers = new Headers(withRecordFenceHeaders().headers);
|
||||
expect(headers.get(RECORD_EPOCH_HEADER)).toBe("4");
|
||||
expect(headers.get(RECORD_SCOPE_HEADER)).toBe(OWNER);
|
||||
});
|
||||
|
||||
it("spells one's own record as the self sentinel", () => {
|
||||
adoptRecordFenceState({ epoch: 2, scope: null });
|
||||
const headers = new Headers(withRecordFenceHeaders().headers);
|
||||
expect(headers.get(RECORD_SCOPE_HEADER)).toBe(RECORD_SCOPE_SELF);
|
||||
});
|
||||
|
||||
it("keeps caller headers and does not overwrite an explicit assertion", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const init = withRecordFenceHeaders({
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
[RECORD_EPOCH_HEADER]: "1",
|
||||
},
|
||||
});
|
||||
const headers = new Headers(init.headers);
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(headers.get(RECORD_EPOCH_HEADER)).toBe("1");
|
||||
});
|
||||
|
||||
it("un-adopts on a null context rather than keeping a stale one", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
adoptRecordFenceState(null);
|
||||
expect(currentRecordFenceState()).toBeNull();
|
||||
expect(currentAssertion().epoch).toBe(RECORD_FENCE_BOOTSTRAP);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the browser does with an echoed context", () => {
|
||||
it("serves a response carrying no echo, and enters no hold", () => {
|
||||
// The `/api/version` case, named because it is the concrete thing a
|
||||
// discard-on-absence rule breaks: it is polled on a timer.
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
const res = publicResponse();
|
||||
expect(validateResponseContext(res)).toBe(res);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves a response whose echo agrees", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
const res = echoing(4, OWNER);
|
||||
expect(validateResponseContext(res)).toBe(res);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a response delayed across a switch, and enters the hold", () => {
|
||||
// Acceptance case 6: the response was already past the fence when the
|
||||
// switch committed, so the server served it honestly under the old context
|
||||
// and said so. The browser is on epoch 5 now.
|
||||
adoptRecordFenceState({ epoch: 5, scope: null });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
expect(() => validateResponseContext(echoing(4, OWNER))).toThrow(
|
||||
RecordContextMismatchError,
|
||||
);
|
||||
expect(hold).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discards on a matching epoch whose scope disagrees", () => {
|
||||
adoptRecordFenceState({ epoch: 4, scope: OWNER });
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
expect(() => validateResponseContext(echoing(4, "somebody-else"))).toThrow(
|
||||
RecordContextMismatchError,
|
||||
);
|
||||
expect(hold).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("serves an echoing response while still unadopted", () => {
|
||||
// Nothing to contradict: the request itself asserted `bootstrap`, so the
|
||||
// server already decided whether it could be served at all.
|
||||
const hold = vi.fn();
|
||||
onRecordFenceMismatch(hold);
|
||||
|
||||
const res = echoing(0, null);
|
||||
expect(validateResponseContext(res)).toBe(res);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not raise an ApiError, because nothing about the exchange failed", () => {
|
||||
adoptRecordFenceState({ epoch: 5, scope: null });
|
||||
try {
|
||||
validateResponseContext(echoing(4, OWNER));
|
||||
throw new Error("expected a throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(RecordContextMismatchError);
|
||||
// A caller branching on `err.status` must not see a status that was
|
||||
// never refused — the response was a 200.
|
||||
expect((err as { status?: number }).status).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases the mismatch handler when its disposer runs", () => {
|
||||
adoptRecordFenceState({ epoch: 5, scope: null });
|
||||
const hold = vi.fn();
|
||||
const dispose = onRecordFenceMismatch(hold);
|
||||
dispose();
|
||||
|
||||
expect(() => validateResponseContext(echoing(4, OWNER))).toThrow(
|
||||
RecordContextMismatchError,
|
||||
);
|
||||
expect(hold).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,10 @@
|
||||
*/
|
||||
|
||||
import { readError } from "./read-error";
|
||||
import {
|
||||
validateResponseContext,
|
||||
withRecordFenceHeaders,
|
||||
} from "./record-fence";
|
||||
|
||||
/** HTTP-level failure from an `/api/...` route — non-OK status. */
|
||||
export class ApiError extends Error {
|
||||
@@ -113,7 +117,9 @@ export async function apiFetch<T = unknown>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, withDefaultTimeout(init));
|
||||
const res = validateResponseContext(
|
||||
await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))),
|
||||
);
|
||||
if (!res.ok) await throwApiError(res);
|
||||
const envelope = await parseEnvelope<T, unknown>(res);
|
||||
return (envelope === undefined ? undefined : envelope.data) as T;
|
||||
@@ -127,7 +133,9 @@ export async function apiFetchEnvelope<
|
||||
T = unknown,
|
||||
M = Record<string, unknown>,
|
||||
>(path: string, init?: RequestInit): Promise<{ data: T; meta: M | undefined }> {
|
||||
const res = await fetch(path, withDefaultTimeout(init));
|
||||
const res = validateResponseContext(
|
||||
await fetch(path, withRecordFenceHeaders(withDefaultTimeout(init))),
|
||||
);
|
||||
if (!res.ok) await throwApiError(res);
|
||||
const envelope = await parseEnvelope<T, M>(res);
|
||||
return {
|
||||
@@ -142,11 +150,20 @@ export async function apiFetchEnvelope<
|
||||
* Performs no `.ok` check, no envelope unwrap, and applies no default
|
||||
* timeout (SSE streams legitimately outlive any fixed window).
|
||||
*/
|
||||
export function apiFetchRaw(
|
||||
export async function apiFetchRaw(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> {
|
||||
return fetch(path, init);
|
||||
// The record-session assertion is attached HERE and not at the ~25 call
|
||||
// sites, several of which are record traffic (the Coach SSE stream, the
|
||||
// document inbound upload, blob exports). A call site that had to classify
|
||||
// itself would be wrong the first time a route changed mode.
|
||||
//
|
||||
// The echo is validated from headers only — the body is never touched, so an
|
||||
// SSE stream and a blob download reach their reader intact.
|
||||
return validateResponseContext(
|
||||
await fetch(path, withRecordFenceHeaders(init)),
|
||||
);
|
||||
}
|
||||
|
||||
function withJsonBody(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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 elsewhere:
|
||||
* `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. A POST-fence cached record response does carry the
|
||||
* echo it was cached with, so replaying it after a switch is a mismatch and is
|
||||
* discarded.
|
||||
*/
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user