From 6da443c875703207d84414146e504d57848a4f7a Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:44:54 +0000 Subject: [PATCH 1/4] feat(web): bind client to scene/result contract with witnesses Export REQUIRED_RESULT_FIELDS from the result module, add a contract fake for fetch-level behavior, and add three test suites: schema conformance against the real server/schemas files (read-only), session witnesses for stale, mismatched and unparseable outcomes, and client tests over the fake's HTTP-shaped boundary. A parseable result naming other bytes is kept in history but never becomes the displayed result. Biome, tsc and vitest pass: 98 tests. Co-authored-by: obvious-autobuild[bot] <262744130+obvious-autobuild[bot]@users.noreply.github.com> --- web/src/lib/contract/client.test.ts | 342 +++++++++++++++ web/src/lib/contract/client.ts | 351 ++++++++++++++++ web/src/lib/contract/contract-fake.ts | 371 +++++++++++++++++ web/src/lib/contract/errors.ts | 127 ++++++ web/src/lib/contract/hash.ts | 20 + web/src/lib/contract/result.ts | 157 +++++++ web/src/lib/contract/scene.ts | 173 ++++++++ .../lib/contract/schema-conformance.test.ts | 393 ++++++++++++++++++ .../lib/contract/session-witnesses.test.ts | 339 +++++++++++++++ web/src/lib/contract/session.ts | 293 +++++++++++++ web/src/lib/contract/types.ts | 244 +++++++++++ web/src/lib/contract/versions.ts | 46 ++ web/tsconfig.app.json | 2 +- 13 files changed, 2857 insertions(+), 1 deletion(-) create mode 100644 web/src/lib/contract/client.test.ts create mode 100644 web/src/lib/contract/client.ts create mode 100644 web/src/lib/contract/contract-fake.ts create mode 100644 web/src/lib/contract/errors.ts create mode 100644 web/src/lib/contract/hash.ts create mode 100644 web/src/lib/contract/result.ts create mode 100644 web/src/lib/contract/scene.ts create mode 100644 web/src/lib/contract/schema-conformance.test.ts create mode 100644 web/src/lib/contract/session-witnesses.test.ts create mode 100644 web/src/lib/contract/session.ts create mode 100644 web/src/lib/contract/types.ts create mode 100644 web/src/lib/contract/versions.ts diff --git a/web/src/lib/contract/client.test.ts b/web/src/lib/contract/client.test.ts new file mode 100644 index 000000000..a9e0acba3 --- /dev/null +++ b/web/src/lib/contract/client.test.ts @@ -0,0 +1,342 @@ +// Client tests: PlacementClient against the contract fake, so the fetch-based +// transport, its exchange receipts and every failure shape are exercised through a +// real HTTP-shaped boundary without a server process. The response hashes are +// recomputed with node:crypto, an implementation independent of the client's +// crypto.subtle path, so a receipt's evidence is checked, not trusted. + +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { PlacementClient } from "./client.ts"; +import { + createContractFake, + type FakeBehavior, + MISMATCHED_SHA256, + sceneText, +} from "./contract-fake.ts"; +import { failureHeadline } from "./errors.ts"; +import { intakeSceneText } from "./scene.ts"; +import { + applyOutcome, + createSession, + newAttemptId, + type SessionRecord, + startAttempt, +} from "./session.ts"; + +interface Submitted { + readonly report: Awaited>; + readonly requestSha256: string; + readonly attemptId: string; +} + +async function submitted( + behavior?: FakeBehavior, + options?: { maxResponseBytes?: number }, +): Promise<{ + fake: ReturnType; + submitted: Submitted; +}> { + const fake = createContractFake(behavior ? { behavior } : {}); + const client = new PlacementClient({ + baseUrl: "http://localhost:8000/", + fetchImpl: fake.fetch, + ...(options?.maxResponseBytes !== undefined + ? { maxResponseBytes: options.maxResponseBytes } + : {}), + }); + const intake = await intakeSceneText(sceneText()); + const attemptId = newAttemptId("t"); + const report = await client.submitPlacement(intake, attemptId, new AbortController().signal); + return { fake, submitted: { report, requestSha256: intake.sha256, attemptId } }; +} + +describe("the bound happy path", () => { + it("posts the scene's exact bytes and binds the answer to them", async () => { + const { fake, submitted: run } = await submitted(); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok) return; + expect(report.outcome.type).toBe("result"); + if (report.outcome.type !== "result") return; + expect(report.outcome.binding).toEqual({ kind: "bound" }); + expect(report.outcome.additiveNotes).toEqual([]); + expect(report.outcome.result.decision).toBe("pass"); + + // The request receipt: what was sent, byte for byte. + expect(report.exchange.request.url).toBe("http://localhost:8000/v1/placements"); + expect(report.exchange.request.method).toBe("POST"); + expect(report.exchange.request.contentType).toBe("application/json"); + expect(report.exchange.request.byteLength).toBe(intakeByteLength()); + expect(report.exchange.request.requestSha256).toBe(run.requestSha256); + + // The fake saw the same bytes, and the answer's hash binds to them. + const sent = fake.requests[0]; + expect(sent?.method).toBe("POST"); + expect(sent?.headers["content-type"]).toBe("application/json"); + expect(sent?.body?.byteLength).toBe(intakeByteLength()); + }); + + it("receipts the response independently: status, type, excerpt, and an oracle-checked hash", async () => { + const { fake, submitted: run } = await submitted(); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok) return; + const response = report.exchange.response; + expect(response).not.toBeNull(); + if (response === null) return; + expect(response.status).toBe(200); + expect(response.contentType).toBe("application/json"); + expect(response.bodyTruncated).toBe(false); + expect(response.bodyExcerpt?.startsWith('{"schema_version":"1.0"')).toBe(true); + expect(response.bodySha256).toBe( + createHash("sha256") + .update(fake.lastResponseText ?? "", "utf8") + .digest("hex"), + ); + expect(response.elapsedMs).toBeGreaterThanOrEqual(0); + expect(response.finishedAt).toBeGreaterThanOrEqual(response.elapsedMs); + }); + + it("feeds the session layer: the bound result becomes the displayed result", async () => { + const { submitted: run } = await submitted(); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok || report.outcome.type !== "result") throw new Error("expected a bound result"); + let session: SessionRecord = createSession("s", "walk", run.requestSha256); + session = startAttempt(session, run.attemptId, report.exchange.request, 1_000); + session = applyOutcome(session, run.attemptId, report.outcome); + expect(session.currentResult?.decision).toBe("pass"); + expect(session.witnesses).toEqual([]); + }); +}); + +describe("mismatched answers", () => { + it("returns a mismatched binding when the result names other bytes", async () => { + const { submitted: run } = await submitted({ kind: "mismatched_sha" }); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok || report.outcome.type !== "result") return; + expect(report.outcome.binding).toEqual({ + kind: "mismatched", + requestSha256: run.requestSha256, + resultSha256: MISMATCHED_SHA256, + }); + }); + + it("never becomes the displayed result through the session layer", async () => { + const { submitted: run } = await submitted({ kind: "mismatched_sha" }); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok || report.outcome.type !== "result") + throw new Error("expected a result outcome"); + let session: SessionRecord = createSession("s", "walk", run.requestSha256); + session = startAttempt(session, run.attemptId, report.exchange.request, 1_000); + session = applyOutcome(session, run.attemptId, report.outcome); + expect(session.currentResult).toBeNull(); + expect(session.witnesses[0]?.kind).toBe("mismatched_result"); + }); +}); + +describe("unparseable answers", () => { + it("a 200 that is not JSON is an unparseable-result outcome, not a result", async () => { + const { submitted: run } = await submitted({ kind: "unparseable_200" }); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok) return; + expect(report.outcome.type).toBe("unparseable_result"); + if (report.outcome.type !== "unparseable_result") return; + expect(report.outcome.failure.reason).toBe("not_json"); + expect(report.exchange.response?.status).toBe(200); + }); + + it("a 200 with a foreign schema_version is refused by the result intake", async () => { + const { submitted: run } = await submitted({ kind: "wrong_version_200" }); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok) return; + expect(report.outcome.type).toBe("unparseable_result"); + if (report.outcome.type !== "unparseable_result") return; + expect(report.outcome.failure.reason).toBe("wrong_version"); + }); + + it("caps an oversized body and refuses to call the remains a result", async () => { + const { submitted: run } = await submitted(undefined, { maxResponseBytes: 128 }); + const { report } = run; + expect(report.ok).toBe(true); + if (!report.ok) return; + expect(report.outcome.type).toBe("unparseable_result"); + if (report.outcome.type !== "unparseable_result") return; + expect(report.outcome.failure.reason).toBe("not_json"); + const response = report.exchange.response; + expect(response?.bodyTruncated).toBe(true); + expect(response?.bodyByteLength).toBeLessThanOrEqual(128); + }); +}); + +describe("server refusals", () => { + it("surfaces the refusal envelope with its code, pointer and known-code flag", async () => { + const { submitted: run } = await submitted({ + kind: "refused", + status: 422, + code: "invalid_scene", + message: "The scene has no meter object.", + path: "/meter", + }); + const { report } = run; + expect(report.ok).toBe(false); + if (report.ok) return; + expect(report.failure).toEqual({ + kind: "server_error", + status: 422, + envelope: { + code: "invalid_scene", + message: "The scene has no meter object.", + path: "/meter", + }, + knownCode: true, + }); + expect(report.exchange.response?.status).toBe(422); + }); + + it("flags an unknown refusal code instead of mashing it into a known one", async () => { + const { submitted: run } = await submitted({ + kind: "refused", + status: 422, + code: "quantum_disagreement", + message: "The scene is in two states at once.", + path: null, + }); + const { report } = run; + expect(report.ok).toBe(false); + if (report.ok) return; + expect(report.failure.kind).toBe("server_error"); + if (report.failure.kind !== "server_error") return; + expect(report.failure.knownCode).toBe(false); + expect(failureHeadline(report.failure)).toContain("does not know"); + }); + + it("classifies an error body that is not the envelope as unparseable_error", async () => { + const { submitted: run } = await submitted({ + kind: "raw", + status: 502, + contentType: "text/html", + bodyText: "bad gateway", + }); + const { report } = run; + expect(report.ok).toBe(false); + if (report.ok) return; + expect(report.failure).toEqual({ + kind: "unparseable_error", + status: 502, + detail: expect.stringContaining("502"), + }); + }); +}); + +describe("transport faults and cancellation", () => { + it("reports a network fault with the request receipt but no response", async () => { + const { submitted: run } = await submitted({ kind: "throws", detail: "connection reset" }); + const { report } = run; + expect(report.ok).toBe(false); + if (report.ok) return; + expect(report.failure).toEqual({ kind: "network_error", detail: "connection reset" }); + expect(report.exchange.response).toBeNull(); + expect(report.exchange.request.requestSha256).toBe(run.requestSha256); + }); + + it("reports cancellation when the signal is already aborted", async () => { + const fake = createContractFake(); + const client = new PlacementClient({ baseUrl: "http://localhost:8000", fetchImpl: fake.fetch }); + const intake = await intakeSceneText(sceneText()); + const controller = new AbortController(); + controller.abort(); + const report = await client.submitPlacement(intake, newAttemptId("t"), controller.signal); + expect(report.ok).toBe(false); + if (report.ok) return; + expect(report.failure).toEqual({ kind: "aborted" }); + expect(fake.requests).toHaveLength(0); + }); + + it("reports cancellation during flight", async () => { + const fake = createContractFake({ behavior: { kind: "delayed_then_abort", delayMs: 100 } }); + const client = new PlacementClient({ baseUrl: "http://localhost:8000", fetchImpl: fake.fetch }); + const intake = await intakeSceneText(sceneText()); + const controller = new AbortController(); + const pending = client.submitPlacement(intake, newAttemptId("t"), controller.signal); + setTimeout(() => controller.abort(), 10); + const report = await pending; + expect(report.ok).toBe(false); + if (report.ok) return; + expect(report.failure).toEqual({ kind: "aborted" }); + }); +}); + +describe("auth and the read-only endpoints", () => { + it("sends the bearer token only when configured", async () => { + const plain = createContractFake(); + const tokened = createContractFake(); + const plainClient = new PlacementClient({ + baseUrl: "http://localhost:8000", + fetchImpl: plain.fetch, + }); + const tokenClient = new PlacementClient({ + baseUrl: "http://localhost:8000", + fetchImpl: tokened.fetch, + authToken: "tok", + }); + const intake = await intakeSceneText(sceneText()); + await plainClient.submitPlacement(intake, newAttemptId("t"), new AbortController().signal); + await tokenClient.submitPlacement(intake, newAttemptId("t"), new AbortController().signal); + expect(plain.requests[0]?.headers.authorization).toBeUndefined(); + expect(tokened.requests[0]?.headers.authorization).toBe("Bearer tok"); + }); + + it("reads /health", async () => { + const fake = createContractFake(); + const client = new PlacementClient({ baseUrl: "http://localhost:8000", fetchImpl: fake.fetch }); + const { health, failure } = await client.getHealth(); + expect(failure).toBeNull(); + expect(health).toEqual({ + status: "ok", + schemaVersion: "1.0", + policy: { id: "public-demo", auto_approve: true }, + }); + expect(fake.requests[0]?.method).toBe("GET"); + }); + + it("classifies a refused health answer", async () => { + const fake = createContractFake({ + behavior: { + kind: "refused", + status: 503, + code: "internal_error", + message: "rules not loaded", + path: null, + }, + applyTo: "/health", + }); + const client = new PlacementClient({ baseUrl: "http://localhost:8000", fetchImpl: fake.fetch }); + const { health, failure } = await client.getHealth(); + expect(health).toBeNull(); + expect(failure).toEqual({ + kind: "server_error", + status: 503, + envelope: { code: "internal_error", message: "rules not loaded", path: null }, + knownCode: true, + }); + }); + + it("reads both schema endpoints", async () => { + const fake = createContractFake(); + const client = new PlacementClient({ baseUrl: "http://localhost:8000", fetchImpl: fake.fetch }); + const { schemas, failure } = await client.getSchemas(); + expect(failure).toBeNull(); + expect(schemas?.scene).toEqual({ type: "object", required: ["meter", "walls"] }); + expect(schemas?.result).toEqual({ type: "object", required: ["schema_version"] }); + }); +}); + +/** The fixture scene's byte length as an upload, computed independently of the client. */ +function intakeByteLength(): number { + return new TextEncoder().encode(sceneText()).byteLength; +} diff --git a/web/src/lib/contract/client.ts b/web/src/lib/contract/client.ts new file mode 100644 index 000000000..714dd3873 --- /dev/null +++ b/web/src/lib/contract/client.ts @@ -0,0 +1,351 @@ +// The portable placement client: one fetch-based transport for the browser and Node +// (vitest), with the fetch implementation injected so tests can point it at the local +// server, the contract fake, or a failing transport. Every call produces receipts — +// what was sent (bounded), what came back (bounded) — because the evidence trail needs +// the actual exchange, not a memory of it. +// +// Scope: this client sends a bare scene.json (application/json), which is what the +// solver reads and what the public deployments accept. Zip bundles and multipart forms +// exist in the contract for image-carrying uploads from the capture flow; this +// inspection client does not upload images and does not use them (see web/README.md). + +import type { PlacementFailure } from "./errors.ts"; +import { parseErrorEnvelope } from "./errors.ts"; +import { sha256Hex } from "./hash.ts"; +import { intakeResultBody } from "./result.ts"; +import type { SceneIntake } from "./scene.ts"; +import type { AttemptOutcome, RequestReceipt, ResponseReceipt } from "./session.ts"; + +/** Response bodies are hashed and excerpted but never stored whole above this cap. */ +const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const EXCERPT_BYTES = 512; + +export interface PlacementClientOptions { + /** Origin of the placement server, e.g. "http://localhost:8000". */ + readonly baseUrl: string; + /** Injectable transport (tests, contract fake). Defaults to globalThis.fetch. */ + readonly fetchImpl?: typeof fetch; + /** Bearer token for servers that loaded private rules. Omitted unless configured. */ + readonly authToken?: string; + readonly maxResponseBytes?: number; +} + +export interface ExchangeRecord { + readonly request: RequestReceipt; + readonly response: ResponseReceipt | null; +} + +export interface SubmitSuccess { + readonly ok: true; + readonly outcome: AttemptOutcome; + readonly exchange: ExchangeRecord; +} + +export interface SubmitFailure { + readonly ok: false; + /** Always present: the transport-level failure (network/abort). */ + readonly failure: PlacementFailure; + readonly exchange: ExchangeRecord; +} + +export type SubmitReport = SubmitSuccess | SubmitFailure; + +export interface ServerHealth { + readonly status: string; + readonly schemaVersion: string; + readonly policy: unknown; +} + +function isAbort(error: unknown, signal: AbortSignal): boolean { + return signal.aborted && error instanceof DOMException && error.name === "AbortError"; +} + +async function readBodyCapped( + response: Response, + maxBytes: number, +): Promise<{ bytes: Uint8Array; truncated: boolean }> { + if (response.body === null) { + return { bytes: new Uint8Array(), truncated: false }; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let truncated = false; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value === undefined) { + continue; + } + total += value.byteLength; + if (total > maxBytes) { + truncated = true; + await reader.cancel(); + break; + } + chunks.push(value); + } + const bytes = new Uint8Array( + total > maxBytes ? chunks.reduce((n, c) => n + c.byteLength, 0) : total, + ); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return { bytes, truncated }; +} + +function excerptOf(bytes: Uint8Array): string | null { + if (bytes.length === 0) { + return null; + } + const slice = bytes.slice(0, Math.min(EXCERPT_BYTES, bytes.length)); + return new TextDecoder("utf-8", { fatal: false }).decode(slice); +} + +export class PlacementClient { + private readonly baseUrl: string; + private readonly fetchImpl: typeof fetch; + private readonly maxResponseBytes: number; + private readonly authToken: string | undefined; + + constructor(options: PlacementClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.fetchImpl = options.fetchImpl ?? fetch; + this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; + this.authToken = options.authToken; + } + + private async buildReceipt( + attemptId: string, + intake: SceneIntake, + startedAt: number, + ): Promise { + return { + attemptId, + url: `${this.baseUrl}/v1/placements`, + method: "POST", + contentType: "application/json", + byteLength: intake.bytes.byteLength, + requestSha256: intake.sha256, + startedAt, + }; + } + + /** + * Posts the scene's exact bytes to /v1/placements. Resolves with a SubmitReport — it + * does not throw for server refusals or network failure; only for caller misuse. + * Cancellation is cooperative: pass an AbortSignal and the resolved report carries an + * "aborted" failure. + */ + async submitPlacement( + intake: SceneIntake, + attemptId: string, + signal: AbortSignal, + ): Promise { + const startedAt = Date.now(); + const request = await this.buildReceipt(attemptId, intake, startedAt); + const headers: Record = { "Content-Type": "application/json" }; + if (this.authToken !== undefined) { + headers.Authorization = `Bearer ${this.authToken}`; + } + + let response: Response; + try { + response = await this.fetchImpl(request.url, { + method: "POST", + headers, + body: intake.bytes as unknown as BodyInit, + signal, + }); + } catch (error) { + const failure: PlacementFailure = isAbort(error, signal) + ? { kind: "aborted" } + : { kind: "network_error", detail: (error as Error).message }; + return { ok: false, failure, exchange: { request, response: null } }; + } + + const finishedAt = Date.now(); + const { bytes, truncated } = await readBodyCapped(response, this.maxResponseBytes); + const responseReceipt: ResponseReceipt = { + attemptId, + status: response.status, + contentType: response.headers.get("content-type") ?? "", + bodyByteLength: bytes.byteLength, + bodySha256: await sha256Hex(bytes), + bodyExcerpt: excerptOf(bytes), + bodyTruncated: truncated, + elapsedMs: finishedAt - startedAt, + finishedAt, + }; + + if (response.status !== 200) { + let parsed: unknown = null; + let jsonbroke: string | null = null; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch (error) { + jsonbroke = (error as Error).message; + } + if (jsonbroke !== null || parsed === null) { + const failure: PlacementFailure = { + kind: "unparseable_error", + status: response.status, + detail: + jsonbroke !== null + ? `HTTP ${response.status} body is not JSON: ${jsonbroke}` + : `HTTP ${response.status} body is empty`, + }; + return { ok: false, failure, exchange: { request, response: responseReceipt } }; + } + const failure = parseErrorEnvelope(response.status, parsed); + return { ok: false, failure, exchange: { request, response: responseReceipt } }; + } + + // 200: parse as a result and bind it to the request bytes. + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch (error) { + return { + ok: true, + outcome: { + type: "unparseable_result", + response: responseReceipt, + failure: { + reason: "not_json", + detail: `A 200 body that is not JSON: ${(error as Error).message}`, + }, + }, + exchange: { request, response: responseReceipt }, + }; + } + const intakeResult = intakeResultBody(parsed); + if (!intakeResult.ok) { + return { + ok: true, + outcome: { + type: "unparseable_result", + response: responseReceipt, + failure: intakeResult.failure, + }, + exchange: { request, response: responseReceipt }, + }; + } + const outcome: AttemptOutcome = { + type: "result", + response: responseReceipt, + result: intakeResult.result, + binding: { kind: "bound" }, + additiveNotes: intakeResult.additiveNotes, + }; + // The binding check lives in one place; run it here too so a client-only caller + // (without the session layer) still gets the mismatch witness. + if (intakeResult.result.stats.input_sha256 !== request.requestSha256) { + return { + ok: true, + outcome: { + ...outcome, + binding: { + kind: "mismatched", + requestSha256: request.requestSha256, + resultSha256: intakeResult.result.stats.input_sha256, + }, + }, + exchange: { request, response: responseReceipt }, + }; + } + return { ok: true, outcome, exchange: { request, response: responseReceipt } }; + } + + /** GET /health — which rules are loaded and their hash. */ + async getHealth( + signal?: AbortSignal, + ): Promise<{ health: ServerHealth | null; failure: PlacementFailure | null }> { + try { + const response = await this.fetchImpl(`${this.baseUrl}/health`, { + method: "GET", + signal: signal ?? null, + }); + if (response.status !== 200) { + const { bytes } = await readBodyCapped(response, this.maxResponseBytes); + let parsed: unknown = null; + try { + parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + parsed = null; + } + return { + health: null, + failure: + parsed === null + ? { + kind: "unparseable_error", + status: response.status, + detail: `HTTP ${response.status}`, + } + : parseErrorEnvelope(response.status, parsed), + }; + } + const body = (await response.json()) as { + status?: unknown; + schema_version?: unknown; + policy?: unknown; + }; + return { + health: { + status: typeof body.status === "string" ? body.status : "unknown", + schemaVersion: typeof body.schema_version === "string" ? body.schema_version : "unknown", + policy: body.policy ?? null, + }, + failure: null, + }; + } catch (error) { + if (signal !== undefined && isAbort(error, signal)) { + return { health: null, failure: { kind: "aborted" } }; + } + return { health: null, failure: { kind: "network_error", detail: (error as Error).message } }; + } + } + + /** GET /v1/schemas/{scene,result}.json — the contract this server honours, for display. */ + async getSchemas(signal?: AbortSignal): Promise<{ + schemas: { scene: unknown; result: unknown } | null; + failure: PlacementFailure | null; + }> { + try { + const [scene, result] = await Promise.all([ + this.fetchImpl(`${this.baseUrl}/v1/schemas/scene.json`, { + method: "GET", + signal: signal ?? null, + }), + this.fetchImpl(`${this.baseUrl}/v1/schemas/result.json`, { + method: "GET", + signal: signal ?? null, + }), + ]); + if (scene.status !== 200 || result.status !== 200) { + return { + schemas: null, + failure: { + kind: "unparseable_error", + status: scene.status !== 200 ? scene.status : result.status, + detail: "A schema endpoint did not answer 200", + }, + }; + } + return { schemas: { scene: await scene.json(), result: await result.json() }, failure: null }; + } catch (error) { + if (signal !== undefined && isAbort(error, signal)) { + return { schemas: null, failure: { kind: "aborted" } }; + } + return { + schemas: null, + failure: { kind: "network_error", detail: (error as Error).message }, + }; + } + } +} diff --git a/web/src/lib/contract/contract-fake.ts b/web/src/lib/contract/contract-fake.ts new file mode 100644 index 000000000..320a5fd70 --- /dev/null +++ b/web/src/lib/contract/contract-fake.ts @@ -0,0 +1,371 @@ +// The contract fake: an in-memory stand-in for the placement server that answers per +// the documented contract, as a fetch implementation, so PlacementClient and the +// session layer run against it unchanged and every test sees a real HTTP-shaped +// exchange. It serves: +// +// - a 200 result whose stats.input_sha256 is the SHA-256 of the exact bytes it +// received (the bound answer), or one that names other bytes (mismatched_sha) — +// the answer a lying or mixed-up server would give; +// - 200s that are not JSON at all, and results with a foreign schema_version; +// - the uniform {"error": {code, message, path}} refusal envelope with any status; +// - error bodies that are not the envelope (raw text/HTML); +// - transport faults thrown out of fetch, and cancellation respected mid-flight. +// +// It records every request it receives and the text of its last response, so tests can +// assert on the exchange receipts, not on memories of them. Its /v1/schemas payloads +// are stubs (the real schema files are read by the schema-conformance tests directly). +// +// This module also holds the shared fixtures: a scene and a result built from the +// TypeScript mirror types and schema-faithful in every field they set — the +// schema-conformance tests validate them against server/schemas/*.schema.json, so one +// honest example backs every test instead of hand-rolled literals. All values are +// synthetic; the repository is public. + +import { sha256Hex } from "./hash.ts"; +import type { PlacementResult, Scene } from "./types.ts"; + +/** A 64-hex digest that is not the fixture's, for mismatch witnesses. */ +export const MISMATCHED_SHA256 = "cd".repeat(32); + +/** 64 hex characters for the fixture's rules_sha256 (pattern ^[0-9a-f]{64}$). */ +export const RULES_SHA256 = "ab".repeat(32); + +/** The scene the tests share: one wall, one object, full coverage, no keyframes (the inspection client uploads no bundle). */ +export function sceneFixture(): Scene { + return { + meter: { pos: [0, 0, 0], wall_id: "north" }, + walls: [ + { + id: "north", + baseline: [ + [-10, 0], + [10, 0], + ], + height_ft: 9, + source: "tap", + plus_minus_ft: 0.2, + }, + ], + objects: [ + { + type: "gas_meter", + wall_id: "north", + span_ft: [6, 7], + bottom_ft: 0.5, + top_ft: 1.5, + attrs: { operable: true }, + source: "tap", + plus_minus_ft: 0.1, + }, + ], + ground: [ + { + type: "concrete", + polygon: [ + [-10, 0], + [10, 0], + [10, 6], + [-10, 6], + ], + plus_minus_ft: 0.3, + }, + ], + overheads: [{ wall_id: "north", span_ft: [-2, 2], clearance_ft: 9, plus_minus_ft: 0.4 }], + facing: [{ wall_id: "north", span_ft: [-10, 10], depth_ft: 12, plus_minus_ft: 0.5 }], + coverage: { + ends: { + left: { kind: "limit", note: "fence" }, + right: { kind: "unexplored", note: "corner" }, + }, + observed: [ + { band: "wall", span_ft: [-10, 10], out_ft: 9 }, + { band: "ground", span_ft: [-10, 10], out_ft: 6, camera_pos_ft: [0, 4] }, + ], + }, + stills: { meter_close: "stills/meter_close.jpg" }, + gps: { lat: 30.2, lon: -97.7 }, + heading: { deg: 270, accuracy_deg: 2 }, + }; +} + +/** The fixture scene's exact text — what the intake hashes and what the upload sends. */ +export function sceneText(): string { + return JSON.stringify(sceneFixture(), null, 2); +} + +/** A schema-faithful result answering for the given scene hash. */ +export function resultFixture(inputSha256: string): PlacementResult { + return { + schema_version: "1.0", + decision: "pass", + summary: "The north wall fits a battery between 3.0 ft and 5.5 ft from the meter.", + reasons: [ + { + code: "all_checks_pass", + message: "Every check at the chosen spot passes with the observed evidence.", + }, + ], + policy: { + id: "public-demo", + version: "2026-09-25", + auto_approve: true, + allow_reject: true, + sources: ["public"], + rules_sha256: RULES_SHA256, + notice: "Demo rules, not the utility's.", + }, + spot: { + outcome: "pass", + wall_id: "north", + segment: 0, + span_ft: [3, 5.5], + width_ft: 2.5, + depth_ft: 1.2, + height_ft: 2, + footprint: [ + [3, 0], + [5.5, 0], + [5.5, 1.2], + [3, 1.2], + ], + center: [4.25, 0.6], + along: [1, 0], + outward: [0, 1], + meter_offset_ft: [4.25, 0.6], + route_length_ft: 4.25, + }, + route: { + outcome: "pass", + length_ft: 4.25, + plus_minus_ft: 0.3, + height_ft: 1, + polyline: [ + [0, 0], + [4.25, 0], + [4.25, 1.2], + ], + detours: [], + crossings: [], + length_is_lower_bound: false, + }, + checks: [ + { + id: "gas_clearance", + label: "Gas clearance", + outcome: "pass", + reason: "The nearest gas meter is 2.0 ft away, 1.0 ft past the required clearance.", + measured_ft: 2, + plus_minus_ft: 0.1, + threshold_ft: 1, + comparison: "at_least", + subject: "objects[0] gas_meter", + rule: { key: "clearances.gas_ft", source: "synthetic fixture value", placeholder: true }, + }, + ], + nearest_considered: null, + missing_evidence: [], + objects_not_used: [], + ends: { + left: { kind: "limit", s_ft: -10, point: [-10, 0] }, + right: { kind: "unexplored", s_ft: 10, point: [10, 0] }, + }, + sweep: [{ wall_id: "north", start_ft: [3, 5.5], outcome: "pass", failing: [], unsure: [] }], + stats: { + candidates: 1, + pass: 1, + unsure: 0, + fail: 0, + elapsed_ms: 12, + input_sha256: inputSha256, + }, + }; +} + +/** One request the fake received, with its headers and body bytes. */ +export interface FakeRequest { + readonly method: string; + readonly url: string; + readonly headers: Record; + readonly body: Uint8Array | null; +} + +export type FakeBehavior = + /** Default: 200 with a result bound to the received bytes. */ + | { readonly kind: "bound" } + /** 200 with a result whose input_sha256 names other bytes. */ + | { readonly kind: "mismatched_sha" } + /** 200 with a body that is not JSON. */ + | { readonly kind: "unparseable_200" } + /** 200 with a result carrying a foreign schema_version. */ + | { readonly kind: "wrong_version_200" } + /** The uniform refusal envelope with any status and code. */ + | { + readonly kind: "refused"; + readonly status: number; + readonly code: string; + readonly message: string; + readonly path: string | null; + } + /** An error body that is not the envelope (HTML, empty, plain text). */ + | { + readonly kind: "raw"; + readonly status: number; + readonly contentType: string; + readonly bodyText: string; + } + /** fetch throws — the transport-level fault. */ + | { readonly kind: "throws"; readonly detail: string } + /** The fetch promise settles only after delayMs, then honours an abort. */ + | { readonly kind: "delayed_then_abort"; readonly delayMs: number }; + +export interface ContractFakeOptions { + readonly behavior?: FakeBehavior; + /** + * Only requests whose URL contains this substring get the behavior; the rest are + * served normally. Empty (default) matches every request. + */ + readonly applyTo?: string; +} + +export interface ContractFake { + readonly fetch: typeof fetch; + readonly requests: readonly FakeRequest[]; + /** The text of the most recent response body, for hashing it independently. */ + readonly lastResponseText: string | null; +} + +function abortError(): DOMException { + return new DOMException("This operation was aborted.", "AbortError"); +} + +function healthPayload(): Record { + return { status: "ok", schema_version: "1.0", policy: { id: "public-demo", auto_approve: true } }; +} + +export function createContractFake(options: ContractFakeOptions = {}): ContractFake { + const behavior: FakeBehavior = options.behavior ?? { kind: "bound" }; + const applyTo = options.applyTo ?? ""; + const requests: FakeRequest[] = []; + let lastResponseText: string | null = null; + + const jsonResponse = (status: number, payload: unknown): Response => { + const text = JSON.stringify(payload); + lastResponseText = text; + return new Response(text, { status, headers: { "content-type": "application/json" } }); + }; + + const textResponse = (status: number, contentType: string, bodyText: string): Response => { + lastResponseText = bodyText; + return new Response(bodyText, { status, headers: { "content-type": contentType } }); + }; + + const errorResponse = ( + status: number, + code: string, + message: string, + path: string | null, + ): Response => jsonResponse(status, { error: { code, message, path } }); + + /** The normal answer for a URL when no behavior targets it. */ + const serveDefault = async ( + url: string, + method: string, + body: Uint8Array | null, + ): Promise => { + if (url.includes("/v1/placements")) { + if (method !== "POST") { + return errorResponse( + 405, + "method_not_allowed", + `${method} is not how scenes are submitted.`, + null, + ); + } + const received = body ?? new Uint8Array(); + return jsonResponse(200, resultFixture(await sha256Hex(received))); + } + if (url.includes("/v1/schemas/scene.json")) { + return jsonResponse(200, { type: "object", required: ["meter", "walls"] }); + } + if (url.includes("/v1/schemas/result.json")) { + return jsonResponse(200, { type: "object", required: ["schema_version"] }); + } + if (url.includes("/health")) { + if (method !== "GET") { + return errorResponse( + 405, + "method_not_allowed", + `${method} is not how health is read.`, + null, + ); + } + return jsonResponse(200, healthPayload()); + } + return errorResponse(404, "not_found", `No contract route for ${method} ${url}.`, null); + }; + + const fakeFetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + const method = (init?.method ?? "GET").toUpperCase(); + const headers: Record = {}; + new Headers(init?.headers).forEach((value, key) => { + headers[key] = value; + }); + const rawBody = init?.body; + const body = + rawBody === undefined || rawBody === null + ? null + : rawBody instanceof Uint8Array + ? rawBody + : new TextEncoder().encode(String(rawBody)); + const signal = init?.signal; + + // A real fetch rejects immediately when the signal is already aborted; the request + // never reaches the wire, so nothing is recorded as received. + if (signal?.aborted) { + throw abortError(); + } + + requests.push({ method, url, headers, body }); + + if (!url.includes(applyTo)) { + return serveDefault(url, method, body); + } + switch (behavior.kind) { + case "bound": + return serveDefault(url, method, body); + case "mismatched_sha": + return jsonResponse(200, resultFixture(MISMATCHED_SHA256)); + case "unparseable_200": + return textResponse(200, "application/json", "This is not JSON at all."); + case "wrong_version_200": { + const received = body ?? new Uint8Array(); + const wrong = { ...resultFixture(await sha256Hex(received)), schema_version: "2.0" }; + return jsonResponse(200, wrong); + } + case "refused": + return errorResponse(behavior.status, behavior.code, behavior.message, behavior.path); + case "raw": + return textResponse(behavior.status, behavior.contentType, behavior.bodyText); + case "throws": + throw new TypeError(behavior.detail); + case "delayed_then_abort": { + await new Promise((resolve) => { + setTimeout(resolve, behavior.delayMs); + }); + if (signal?.aborted) { + throw abortError(); + } + return serveDefault(url, method, body); + } + } + }; + + return { + fetch: fakeFetch, + requests, + get lastResponseText() { + return lastResponseText; + }, + }; +} diff --git a/web/src/lib/contract/errors.ts b/web/src/lib/contract/errors.ts new file mode 100644 index 000000000..7458d0fbf --- /dev/null +++ b/web/src/lib/contract/errors.ts @@ -0,0 +1,127 @@ +// The placement server's error contract: every refusal is the same JSON envelope, +// {"error": {"code", "message", "path"}}, where path is a JSON pointer into scene.json +// when one field is to blame (server/api.py module docstring). Codes this client knows +// are named; anything else is surfaced as an unknown code instead of being mashed into +// a generic failure, so a new server code is visible, not swallowed. + +export type PlacementErrorCode = + // 400: the body or bundle cannot be read at all. + | "empty_body" + | "unreadable_zip" + | "unsafe_zip_entry" + | "missing_scene_json" + | "unreadable_multipart" + | "missing_bundle" + // 413: size limits. + | "body_too_large" + | "scene_too_large" + | "bundle_too_many_entries" + | "bundle_directory_too_large" + | "bundle_too_large" + // 415: wrong content type. + | "unsupported_media_type" + // 422: the scene is rejected as-is. + | "invalid_json" + | "invalid_scene" + | "scene_too_complex" + | "missing_bundle_file" + // Auth (only while private rules are loaded). + | "unauthorized" + | "no_api_key" + // Fallbacks for Starlette-raised HTTP errors and unhandled faults. + | "bad_request" + | "not_found" + | "method_not_allowed" + | "internal_error"; + +export interface ErrorEnvelope { + readonly code: string; + readonly message: string; + readonly path: string | null; +} + +export type PlacementFailure = + /** The server answered with its error envelope. */ + | { + readonly kind: "server_error"; + readonly status: number; + readonly envelope: ErrorEnvelope; + readonly knownCode: boolean; + } + /** The server answered, but not with the contract's envelope. */ + | { readonly kind: "unparseable_error"; readonly status: number; readonly detail: string } + /** The request never completed. */ + | { readonly kind: "network_error"; readonly detail: string } + /** The caller aborted the request (fetch threw AbortError). */ + | { readonly kind: "aborted" }; + +export function parseErrorEnvelope(status: number, body: unknown): PlacementFailure { + const candidate = body as { + error?: { code?: unknown; message?: unknown; path?: unknown }; + } | null; + const err = candidate?.error; + if ( + err === undefined || + err === null || + typeof err !== "object" || + typeof err.code !== "string" || + typeof err.message !== "string" + ) { + return { + kind: "unparseable_error", + status, + detail: `HTTP ${status} with a body that is not the documented {"error": {code, message, path}} envelope`, + }; + } + const path = typeof err.path === "string" ? err.path : null; + const envelope: ErrorEnvelope = { code: err.code, message: err.message, path }; + return { kind: "server_error", status, envelope, knownCode: isKnownErrorCode(err.code) }; +} + +const KNOWN_CODES: readonly PlacementErrorCode[] = [ + "empty_body", + "unreadable_zip", + "unsafe_zip_entry", + "missing_scene_json", + "unreadable_multipart", + "missing_bundle", + "body_too_large", + "scene_too_large", + "bundle_too_many_entries", + "bundle_directory_too_large", + "bundle_too_large", + "unsupported_media_type", + "invalid_json", + "invalid_scene", + "scene_too_complex", + "missing_bundle_file", + "unauthorized", + "no_api_key", + "bad_request", + "not_found", + "method_not_allowed", + "internal_error", +]; + +export function isKnownErrorCode(code: string): code is PlacementErrorCode { + return (KNOWN_CODES as readonly string[]).includes(code); +} + +/** + * The single-line verdict a user interface should lead with, per failure kind. The + * server's own message stays available; this classifies before quoting. + */ +export function failureHeadline(failure: PlacementFailure): string { + switch (failure.kind) { + case "server_error": + return failure.knownCode + ? `The server refused the scene (${failure.envelope.code}).` + : `The server refused the scene with an error code this client does not know (${failure.envelope.code}).`; + case "unparseable_error": + return "The server's answer was not the documented error envelope."; + case "network_error": + return "The server could not be reached."; + case "aborted": + return "The submission was cancelled."; + } +} diff --git a/web/src/lib/contract/hash.ts b/web/src/lib/contract/hash.ts new file mode 100644 index 000000000..4c55fa2a8 --- /dev/null +++ b/web/src/lib/contract/hash.ts @@ -0,0 +1,20 @@ +// SHA-256 over exact bytes, portable across the browser and Node: both provide +// crypto.subtle (the web view and vitest's node runtime alike). The placement result +// reports stats.input_sha256 over the scene bytes "as uploaded" (server/scene.py hashes +// the exact payload it parsed), so the client hashes the very bytes it will send — not a +// re-serialization — and association compares the two digests. + +const encoder = new TextEncoder(); + +export async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + bytes as unknown as ArrayBufferView, + ); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** Convenience for hashing a JSON text as UTF-8. */ +export async function sha256HexOfText(text: string): Promise { + return sha256Hex(encoder.encode(text)); +} diff --git a/web/src/lib/contract/result.ts b/web/src/lib/contract/result.ts new file mode 100644 index 000000000..c9edbb9c0 --- /dev/null +++ b/web/src/lib/contract/result.ts @@ -0,0 +1,157 @@ +// Result intake and association. Two jobs: +// +// 1. Parse a 200 response body defensively. The result schema is additive — new fields +// are optional, and an old result missing them still validates — so a missing +// optional field is tolerated and noted, never treated as damage. A missing REQUIRED +// field, a wrong schema_version, or a non-object body is not a result at all. +// 2. Bind the parsed result to the submission that produced it. The contract gives the +// binding: stats.input_sha256 is the hash of the scene bytes as uploaded, which the +// client computed before sending. A result whose hash names different bytes is a +// witness of a mismatched answer — recorded, never displayed as this submission's. +// Whether a late answer is stale (belongs to an older attempt) is the session's call +// (applyOutcome in session.ts), because ordering is session state, not body state. + +import type { PlacementResult } from "./types.ts"; +import { RESULT_VERSION } from "./versions.ts"; + +export type ResultIntakeFailure = + | { readonly reason: "not_json"; readonly detail: string } + | { readonly reason: "not_object"; readonly detail: string } + | { readonly reason: "wrong_version"; readonly detail: string } + | { readonly reason: "missing_required"; readonly detail: string } + | { readonly reason: "wrong_shape"; readonly detail: string }; + +export type ResultIntake = + | { + readonly ok: true; + readonly result: PlacementResult; + readonly additiveNotes: readonly string[]; + } + | { readonly ok: false; readonly failure: ResultIntakeFailure }; + +/** + * Top-level fields result.schema.json requires, in the schema's order. Exported so the + * schema-conformance tests can compare this list with the schema file itself and keep + * intake honest; intake treats the absence of any of them as fatal. + */ +export const REQUIRED_RESULT_FIELDS = [ + "schema_version", + "decision", + "summary", + "reasons", + "policy", + "spot", + "route", + "checks", + "missing_evidence", + "ends", + "sweep", + "stats", +] as const; + +/** + * Optional-by-schema fields this build knows how to render. A result from an older + * server without them is still valid; each absence becomes one additiveNote so the UI + * can say what the answer did not carry rather than rendering silence. + */ +const ADDITIVE_FIELDS = [ + ["policy", "allow_reject"], + ["policy", "notice"], + ["nearest_considered"], + ["objects_not_used"], + ["route", "length_is_lower_bound"], +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function intakeResultBody(body: unknown): ResultIntake { + if (!isRecord(body)) { + return { + ok: false, + failure: { reason: "not_object", detail: "The result body is not a JSON object." }, + }; + } + if (body.schema_version !== RESULT_VERSION) { + return { + ok: false, + failure: { + reason: "wrong_version", + detail: `The result names schema_version ${JSON.stringify(body.schema_version)}; this client reads ${RESULT_VERSION} only.`, + }, + }; + } + const missing = REQUIRED_RESULT_FIELDS.filter((field) => !(field in body)); + if (missing.length > 0) { + return { + ok: false, + failure: { + reason: "missing_required", + detail: `The result is missing required fields: ${missing.join(", ")}.`, + }, + }; + } + const stats = body.stats; + if ( + !isRecord(stats) || + typeof stats.input_sha256 !== "string" || + stats.input_sha256.length !== 64 + ) { + return { + ok: false, + failure: { + reason: "wrong_shape", + detail: + "The result's stats.input_sha256 is not a SHA-256 hex digest, so the answer cannot be bound to an upload.", + }, + }; + } + const decision = body.decision; + if (decision !== "pass" && decision !== "manual_review" && decision !== "reject") { + return { + ok: false, + failure: { + reason: "wrong_shape", + detail: `The result's decision is not pass, manual_review or reject.`, + }, + }; + } + const additiveNotes: string[] = []; + for (const [field, sub] of ADDITIVE_FIELDS) { + const holder = body[field]; + const absent = + sub === undefined + ? holder === undefined + : !isRecord(holder) || (holder as Record)[sub] === undefined; + if (absent) { + additiveNotes.push( + sub === undefined + ? `The result has no ${field} — an older server answer without that additive field.` + : `The result's ${field} has no ${sub} — an older server answer without that additive field.`, + ); + } + } + return { ok: true, result: body as unknown as PlacementResult, additiveNotes }; +} + +export type ResultBinding = + | { readonly kind: "bound" } + | { + readonly kind: "mismatched"; + /** The hash the request carried. */ + readonly requestSha256: string; + /** The hash the result says it answered. */ + readonly resultSha256: string; + }; + +/** + * Binds a parsed result to the request bytes. "bound" only when the result answers the + * exact bytes this submission sent; "mismatched" is a witness, never a display. + */ +export function bindResultToRequest(result: PlacementResult, requestSha256: string): ResultBinding { + const resultSha256 = result.stats.input_sha256; + return resultSha256 === requestSha256 + ? { kind: "bound" } + : { kind: "mismatched", requestSha256, resultSha256 }; +} diff --git a/web/src/lib/contract/scene.ts b/web/src/lib/contract/scene.ts new file mode 100644 index 000000000..3eb7b19b6 --- /dev/null +++ b/web/src/lib/contract/scene.ts @@ -0,0 +1,173 @@ +// Scene intake: turn a local scene file's exact text into the bytes this client will +// upload, a typed view for rendering, and a version report. The placement server is the +// authority on whether a scene is valid — its refusals carry the code and the JSON +// pointer of the field at fault. This module only catches what is cheap and unambiguous +// locally (unreadable JSON, wrong top-level shape, an unsupported schema version), so a +// clearly broken file never makes a pointless round trip, and everything else is sent +// byte-for-byte so the server's answer binds to the exact upload. + +import { sha256HexOfText } from "./hash.ts"; +import type { Scene } from "./types.ts"; +import { reportSceneVersion, type SceneVersionReport } from "./versions.ts"; + +export interface SceneIntakeIssue { + /** Mirrors the server's envelope: a code, a message, and a JSON pointer when one field is at fault. */ + readonly code: string; + readonly message: string; + readonly path: string | null; +} + +export class SceneIntakeError extends Error implements SceneIntakeIssue { + readonly code: string; + readonly path: string | null; + + constructor(issue: SceneIntakeIssue) { + super(issue.message); + this.name = "SceneIntakeError"; + this.code = issue.code; + this.path = issue.path; + } +} + +export interface SceneIntake { + readonly scene: Scene; + /** The file's exact text; the upload sends these bytes, never a re-serialization. */ + readonly text: string; + readonly bytes: Uint8Array; + /** SHA-256 of the exact upload bytes — what stats.input_sha256 must equal for a match. */ + readonly sha256: string; + readonly version: SceneVersionReport; + /** + * Findings that do not block submission but the reviewer should see: a minor schema + * version this build has not tested against, for example. Backed by the advisory + * shape check below. + */ + readonly warnings: readonly string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isNumberArray(value: unknown, length: number): value is number[] { + return ( + Array.isArray(value) && + value.length === length && + value.every((n) => typeof n === "number" && Number.isFinite(n)) + ); +} + +/** + * Local checks on the fields the client itself renders. Strictly advisory: the server + * re-checks everything (unknown fields are rejected server-side, so nothing here can + * widen the contract). Each finding is a refusal with the same code/message/path shape + * the server uses. + */ +function advisoryChecks(raw: unknown): SceneIntakeIssue | null { + if (!isRecord(raw)) { + return { code: "invalid_scene", message: "The scene is not a JSON object.", path: null }; + } + if (!isRecord(raw.meter)) { + return { code: "invalid_scene", message: "The scene has no meter object.", path: "/meter" }; + } + if (!isNumberArray(raw.meter.pos, 3)) { + return { + code: "invalid_scene", + message: "The meter's pos is not three finite numbers [x, y, z] in feet.", + path: "/meter/pos", + }; + } + if (typeof raw.meter.wall_id !== "string" || raw.meter.wall_id.length === 0) { + return { + code: "invalid_scene", + message: "The meter names no wall_id.", + path: "/meter/wall_id", + }; + } + if (!Array.isArray(raw.walls) || raw.walls.length === 0) { + return { code: "invalid_scene", message: "The scene lists no walls.", path: "/walls" }; + } + for (const [index, wall] of raw.walls.entries()) { + if (!isRecord(wall) || typeof wall.id !== "string" || wall.id.length === 0) { + return { + code: "invalid_scene", + message: `Wall ${index} has no id.`, + path: `/walls/${index}/id`, + }; + } + if ( + !isRecord(wall) || + !Array.isArray(wall.baseline) || + wall.baseline.length < 2 || + !wall.baseline.every((p) => isNumberArray(p, 2)) + ) { + return { + code: "invalid_scene", + message: `Wall ${wall?.id ?? index}'s baseline is not at least two [x, z] points.`, + path: `/walls/${index}/baseline`, + }; + } + } + return null; +} + +/** + * Parses a scene file's exact text. Throws SceneIntakeError for anything the client + * refuses locally; otherwise the intake carries the exact bytes to upload and their + * hash. `sha256` is computed asynchronously, so use `intakeSceneText` when the hash is + * needed immediately (the common case). + */ +export function parseSceneText(text: string): { + scene: Scene; + version: SceneVersionReport; + warnings: string[]; +} { + let raw: unknown; + try { + raw = JSON.parse(text) as unknown; + } catch (error) { + throw new SceneIntakeError({ + code: "invalid_json", + message: `The file is not valid JSON: ${(error as Error).message}`, + path: null, + }); + } + const unsupported = refuseUnsupportedVersion(raw); + if (unsupported !== null) { + throw new SceneIntakeError(unsupported); + } + const advisory = advisoryChecks(raw); + if (advisory !== null) { + throw new SceneIntakeError(advisory); + } + const version = reportSceneVersion((raw as { schema_version?: unknown }).schema_version); + const warnings: string[] = []; + if (version.kind === "compatible_untested_minor") { + warnings.push( + `Scene schema version ${version.version} is newer than the ${version.testedVersion} this build was tested ` + + "against. Minor versions only add optional fields, so it should read correctly; nothing here has verified that.", + ); + } + return { scene: raw as Scene, version, warnings }; +} + +function refuseUnsupportedVersion(raw: unknown): SceneIntakeIssue | null { + const version = isRecord(raw) ? raw.schema_version : undefined; + if (version === undefined) { + return null; // Absent means "1.0" per the schema. + } + if (typeof version !== "string" || !/^1\.[0-9]+$/.test(version)) { + return { + code: "unsupported_schema_version", + message: `The scene names schema_version ${JSON.stringify(version)}; this client reads 1.x scenes only.`, + path: "/schema_version", + }; + } + return null; +} + +export async function intakeSceneText(text: string): Promise { + const { scene, version, warnings } = parseSceneText(text); + const bytes = new TextEncoder().encode(text); + return { scene, text, bytes, sha256: await sha256HexOfText(text), version, warnings }; +} diff --git a/web/src/lib/contract/schema-conformance.test.ts b/web/src/lib/contract/schema-conformance.test.ts new file mode 100644 index 000000000..3789c9df6 --- /dev/null +++ b/web/src/lib/contract/schema-conformance.test.ts @@ -0,0 +1,393 @@ +// Schema-conformance tests: the contract folder's TypeScript mirror is checked against +// the real schema files the server publishes (server/schemas/*.schema.json), per the +// mirror's own header comment. A schema change that matters to this client fails here +// instead of drifting silently. The validator below implements the JSON Schema subset +// these two schemas use (draft 2020-12: type, const, enum, required, additionalProperties: +// false, items, minItems/maxItems, minimum/exclusiveMinimum/maximum, minLength, pattern, +// oneOf, if/then, $defs/$ref) — just enough to prove the fixtures honest; it is not a +// general validator. The schema files are read read-only; nothing under server/ changes. + +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { RULES_SHA256, resultFixture, sceneFixture, sceneText } from "./contract-fake.ts"; +import { sha256HexOfText } from "./hash.ts"; +import { intakeResultBody, REQUIRED_RESULT_FIELDS } from "./result.ts"; +import { intakeSceneText, parseSceneText, SceneIntakeError } from "./scene.ts"; +import { + RESULT_VERSION, + reportResultVersion, + reportSceneVersion, + TESTED_SCENE_VERSION, +} from "./versions.ts"; + +interface SchemaNode { + $ref?: string; + $defs?: Record; + type?: string | string[]; + const?: unknown; + enum?: unknown[]; + required?: string[]; + properties?: Record; + additionalProperties?: boolean; + items?: SchemaNode; + minItems?: number; + maxItems?: number; + minimum?: number; + exclusiveMinimum?: number; + maximum?: number; + minLength?: number; + pattern?: string; + oneOf?: SchemaNode[]; + if?: SchemaNode; + then?: SchemaNode; +} + +function loadSchema(fileName: string): SchemaNode { + const file = fileURLToPath(new URL(`../../../../server/schemas/${fileName}`, import.meta.url)); + return JSON.parse(readFileSync(file, "utf8")) as SchemaNode; +} + +const sceneSchema = loadSchema("scene.schema.json"); +const resultSchema = loadSchema("result.schema.json"); + +function validate(value: unknown, schema: SchemaNode, root: SchemaNode, path: string): string[] { + if (schema.$ref !== undefined) { + const name = schema.$ref.replace("#/$defs/", ""); + const def = root.$defs?.[name]; + if (def === undefined) { + return [`${path}: unknown $ref ${schema.$ref}`]; + } + return validate(value, def, root, path); + } + const errors: string[] = []; + if (schema.type !== undefined) { + const types = Array.isArray(schema.type) ? schema.type : [schema.type]; + const matches = types.some((type) => { + switch (type) { + case "object": + return typeof value === "object" && value !== null && !Array.isArray(value); + case "array": + return Array.isArray(value); + case "string": + return typeof value === "string"; + case "integer": + return typeof value === "number" && Number.isInteger(value); + case "number": + return typeof value === "number"; + case "boolean": + return typeof value === "boolean"; + case "null": + return value === null; + default: + return false; + } + }); + if (!matches) { + return [`${path}: expected type ${types.join("|")}`]; + } + } + if (schema.const !== undefined && JSON.stringify(value) !== JSON.stringify(schema.const)) { + errors.push(`${path}: expected const ${JSON.stringify(schema.const)}`); + } + if ( + schema.enum !== undefined && + !schema.enum.some((option) => JSON.stringify(option) === JSON.stringify(value)) + ) { + errors.push(`${path}: ${JSON.stringify(value)} is not one of ${JSON.stringify(schema.enum)}`); + } + if ( + typeof value === "string" && + schema.minLength !== undefined && + value.length < schema.minLength + ) { + errors.push(`${path}: shorter than minLength ${schema.minLength}`); + } + if ( + typeof value === "string" && + schema.pattern !== undefined && + !new RegExp(schema.pattern).test(value) + ) { + errors.push(`${path}: does not match pattern ${schema.pattern}`); + } + if (typeof value === "number") { + if (schema.minimum !== undefined && value < schema.minimum) { + errors.push(`${path}: below minimum ${schema.minimum}`); + } + if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) { + errors.push(`${path}: at or below exclusiveMinimum ${schema.exclusiveMinimum}`); + } + if (schema.maximum !== undefined && value > schema.maximum) { + errors.push(`${path}: above maximum ${schema.maximum}`); + } + } + if (Array.isArray(value)) { + if (schema.minItems !== undefined && value.length < schema.minItems) { + errors.push(`${path}: fewer than ${schema.minItems} items`); + } + if (schema.maxItems !== undefined && value.length > schema.maxItems) { + errors.push(`${path}: more than ${schema.maxItems} items`); + } + if (schema.items !== undefined) { + value.forEach((item, index) => { + errors.push(...validate(item, schema.items as SchemaNode, root, `${path}/${index}`)); + }); + } + } + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const record = value as Record; + for (const key of schema.required ?? []) { + if (!(key in record)) { + errors.push(`${path}: missing required ${key}`); + } + } + for (const [key, child] of Object.entries(schema.properties ?? {})) { + if (key in record) { + errors.push(...validate(record[key], child, root, `${path}/${key}`)); + } + } + if (schema.additionalProperties === false && schema.properties !== undefined) { + for (const key of Object.keys(record)) { + if (!Object.hasOwn(schema.properties, key)) { + errors.push(`${path}: unknown field ${key}`); + } + } + } + } + if (schema.oneOf !== undefined) { + const matching = schema.oneOf.filter( + (branch) => validate(value, branch, root, path).length === 0, + ); + if (matching.length !== 1) { + errors.push(`${path}: matches ${matching.length} oneOf branches, expected exactly 1`); + } + } + if ( + schema.if !== undefined && + schema.then !== undefined && + validate(value, schema.if, root, path).length === 0 + ) { + errors.push(...validate(value, schema.then, root, path)); + } + return errors; +} + +describe("REQUIRED_RESULT_FIELDS against result.schema.json", () => { + it("names exactly the top-level fields the schema requires", () => { + const required = resultSchema.required ?? []; + expect([...REQUIRED_RESULT_FIELDS].sort()).toEqual([...required].sort()); + expect(new Set(REQUIRED_RESULT_FIELDS).size).toBe(REQUIRED_RESULT_FIELDS.length); + }); +}); + +describe("version constants against the schemas", () => { + it("RESULT_VERSION is the result schema's const for schema_version", () => { + expect(resultSchema.properties?.schema_version?.const).toBe(RESULT_VERSION); + }); + + it("TESTED_SCENE_VERSION follows the scene schema's version pattern", () => { + const pattern = sceneSchema.properties?.schema_version?.pattern; + expect(pattern).toBeDefined(); + const regex = new RegExp(pattern ?? ""); + expect(regex.test(TESTED_SCENE_VERSION)).toBe(true); + expect(regex.test("1.9")).toBe(true); + expect(regex.test("2.0")).toBe(false); + }); +}); + +describe("the TypeScript mirror against the real schemas", () => { + it("a scene built from the mirror types validates against scene.schema.json", () => { + expect(validate(sceneFixture(), sceneSchema, sceneSchema, "")).toEqual([]); + }); + + it("a result built from the mirror types validates against result.schema.json", () => { + expect(validate(resultFixture(RULES_SHA256), resultSchema, resultSchema, "")).toEqual([]); + }); + + it("the scene intake accepts the fixture and hashes the exact bytes (node:crypto oracle)", async () => { + const text = sceneText(); + const intake = await intakeSceneText(text); + expect(intake.version).toEqual({ kind: "supported", version: "1.0" }); + expect(intake.warnings).toEqual([]); + expect(intake.sha256).toBe(createHash("sha256").update(text, "utf8").digest("hex")); + expect(intake.text).toBe(text); + }); + + it("the result intake accepts the fixture and binds it to its own hash", () => { + const body: unknown = JSON.parse(JSON.stringify(resultFixture(RULES_SHA256))); + const intake = intakeResultBody(body); + expect(intake.ok).toBe(true); + if (!intake.ok) return; + expect(intake.additiveNotes).toEqual([]); + expect(intake.result.stats.input_sha256).toBe(RULES_SHA256); + }); + + it("the intake's hash equals hash.ts's text hash, byte for byte", async () => { + const text = sceneText(); + const intake = await intakeSceneText(text); + expect(intake.sha256).toBe(await sha256HexOfText(text)); + }); + + it("a scene without walls fails validation", () => { + const broken = JSON.parse(sceneText()) as Record; + Reflect.deleteProperty(broken, "walls"); + const errors = validate(broken, sceneSchema, sceneSchema, ""); + expect(errors.some((error) => error.includes("missing required walls"))).toBe(true); + }); + + it("a one-point wall baseline fails validation at /walls/0/baseline", () => { + const broken = JSON.parse(sceneText()) as { walls?: Array> }; + const wall = broken.walls?.[0]; + if (wall === undefined) throw new Error("scene fixture lost its wall"); + wall.baseline = [[0, 0]]; + const errors = validate(broken, sceneSchema, sceneSchema, ""); + expect(errors.some((error) => error.startsWith("/walls/0/baseline"))).toBe(true); + }); + + it("an unknown result field fails (additionalProperties: false)", () => { + const body: unknown = { ...resultFixture(RULES_SHA256), extra: true }; + const errors = validate(body, resultSchema, resultSchema, ""); + expect(errors.some((error) => error.includes("unknown field extra"))).toBe(true); + }); + + it("a result decision outside the enum fails at /decision", () => { + const body: unknown = { ...resultFixture(RULES_SHA256), decision: "maybe" }; + const errors = validate(body, resultSchema, resultSchema, ""); + expect(errors.some((error) => error.includes("/decision"))).toBe(true); + }); + + it("a result hash that is not 64 hex fails at /stats/input_sha256", () => { + const body: unknown = { ...resultFixture("short") }; + const errors = validate(body, resultSchema, resultSchema, ""); + expect(errors.some((error) => error.startsWith("/stats/input_sha256"))).toBe(true); + }); +}); + +describe("scene intake refusals mirror the server's envelope shape", () => { + function refusalOf(text: string): SceneIntakeError { + try { + parseSceneText(text); + } catch (error) { + if (error instanceof SceneIntakeError) return error; + throw error; + } + throw new Error("expected the scene intake to refuse this text"); + } + + function mutateScene(apply: (scene: Record) => void): string { + const scene = JSON.parse(sceneText()) as Record; + apply(scene); + return JSON.stringify(scene); + } + + it("refuses text that is not JSON", () => { + const refusal = refusalOf("this is not json"); + expect(refusal.code).toBe("invalid_json"); + expect(refusal.path).toBeNull(); + }); + + it("refuses a top-level array", () => { + const refusal = refusalOf("[]"); + expect(refusal.code).toBe("invalid_scene"); + expect(refusal.path).toBeNull(); + }); + + it("refuses a scene without a meter at /meter", () => { + const refusal = refusalOf(mutateScene((scene) => Reflect.deleteProperty(scene, "meter"))); + expect(refusal.code).toBe("invalid_scene"); + expect(refusal.path).toBe("/meter"); + }); + + it("refuses a meter pos that is not three finite numbers at /meter/pos", () => { + const refusal = refusalOf( + mutateScene((scene) => { + const meter = scene.meter as Record; + meter.pos = [1, 2]; + }), + ); + expect(refusal.path).toBe("/meter/pos"); + }); + + it("refuses a meter that names no wall at /meter/wall_id", () => { + const refusal = refusalOf( + mutateScene((scene) => { + const meter = scene.meter as Record; + meter.wall_id = ""; + }), + ); + expect(refusal.path).toBe("/meter/wall_id"); + }); + + it("refuses a scene with no walls at /walls", () => { + const refusal = refusalOf(mutateScene((scene) => Reflect.deleteProperty(scene, "walls"))); + expect(refusal.path).toBe("/walls"); + }); + + it("refuses a wall without an id at /walls/0/id", () => { + const refusal = refusalOf( + mutateScene((scene) => { + const walls = scene.walls as Array>; + const wall = walls[0]; + if (wall === undefined) throw new Error("scene fixture lost its wall"); + wall.id = ""; + }), + ); + expect(refusal.path).toBe("/walls/0/id"); + }); + + it("refuses a one-point baseline at /walls/0/baseline", () => { + const refusal = refusalOf( + mutateScene((scene) => { + const walls = scene.walls as Array>; + const wall = walls[0]; + if (wall === undefined) throw new Error("scene fixture lost its wall"); + wall.baseline = [[0, 0]]; + }), + ); + expect(refusal.path).toBe("/walls/0/baseline"); + }); + + it("refuses a schema_version outside 1.x at /schema_version", () => { + const refusal = refusalOf( + mutateScene((scene) => { + scene.schema_version = "2.0"; + }), + ); + expect(refusal.code).toBe("unsupported_schema_version"); + expect(refusal.path).toBe("/schema_version"); + }); + + it("accepts a newer 1.x minor with an untested-version warning", () => { + const parsed = parseSceneText( + mutateScene((scene) => { + scene.schema_version = "1.1"; + }), + ); + expect(parsed.version).toEqual({ + kind: "compatible_untested_minor", + version: "1.1", + testedVersion: "1.0", + }); + expect(parsed.warnings).toHaveLength(1); + }); +}); + +describe("schema version classification", () => { + it.each([ + [undefined, { kind: "supported", version: "1.0" }], + ["1.0", { kind: "supported", version: "1.0" }], + ["", { kind: "supported", version: "1.0" }], + ["1.9", { kind: "compatible_untested_minor", version: "1.9", testedVersion: "1.0" }], + ["2.0", { kind: "unsupported", version: "2.0" }], + ["one", { kind: "unsupported", version: "one" }], + ])("reportSceneVersion(%j) is %j", (input, expected) => { + expect(reportSceneVersion(input)).toEqual(expected); + }); + + it("a result version other than the contract's one is refused, not guessed", () => { + expect(reportResultVersion("1.0")).toEqual({ ok: true, version: "1.0" }); + expect(reportResultVersion("1.1")).toEqual({ ok: false, version: "1.1" }); + expect(reportResultVersion(undefined).ok).toBe(false); + }); +}); diff --git a/web/src/lib/contract/session-witnesses.test.ts b/web/src/lib/contract/session-witnesses.test.ts new file mode 100644 index 000000000..057f0d2ed --- /dev/null +++ b/web/src/lib/contract/session-witnesses.test.ts @@ -0,0 +1,339 @@ +// Witness tests for the session layer, one per witness the contract names: +// +// - stale: an outcome for a superseded attempt is recorded and never applied; +// - mismatched: a result naming other bytes is a witness, never the displayed result; +// - unparseable (missing): a 200 whose body is not a result is a witness, never displayed. +// +// Plus the display rules around them: a refused, failed or cancelled attempt strips the +// displayed result, the history keeps every outcome with its own binding, and starting +// a new attempt demotes the previous one. Pure state under test: no fetch, no real +// clock (every now is explicit). The result-intake refusals behind the "missing" +// witness are covered here too, because that is where their failures surface. + +import { describe, expect, it } from "vitest"; +import { MISMATCHED_SHA256, RULES_SHA256, resultFixture } from "./contract-fake.ts"; +import type { PlacementFailure } from "./errors.ts"; +import { bindResultToRequest, intakeResultBody, REQUIRED_RESULT_FIELDS } from "./result.ts"; +import type { AttemptOutcome, RequestReceipt, ResponseReceipt } from "./session.ts"; +import { + applyOutcome, + createSession, + newAttemptId, + outcomeFromBody, + type SessionRecord, + startAttempt, +} from "./session.ts"; + +const SHA = RULES_SHA256; + +function requestReceipt(attemptId: string, sha: string): RequestReceipt { + return { + attemptId, + url: "http://localhost:8000/v1/placements", + method: "POST", + contentType: "application/json", + byteLength: 1024, + requestSha256: sha, + startedAt: 1_000, + }; +} + +function responseReceipt(attemptId: string): ResponseReceipt { + return { + attemptId, + status: 200, + contentType: "application/json", + bodyByteLength: 2048, + bodySha256: SHA, + bodyExcerpt: "{}", + bodyTruncated: false, + elapsedMs: 5, + finishedAt: 1_005, + }; +} + +function startAttemptAt( + session: SessionRecord, + attemptId: string, + sha: string, + now: number, +): SessionRecord { + return startAttempt(session, attemptId, requestReceipt(attemptId, sha), now); +} + +/** A result outcome the way the client produces it: parsed from the body, binding attached. */ +function resultOutcome(attemptId: string, requestSha: string, resultSha: string): AttemptOutcome { + const body: unknown = JSON.parse(JSON.stringify(resultFixture(resultSha))); + return outcomeFromBody(responseReceipt(attemptId), body, requestSha); +} + +function attemptOf(session: SessionRecord, attemptId: string) { + const attempt = session.attempts.find((candidate) => candidate.attemptId === attemptId); + if (attempt === undefined) throw new Error(`no attempt ${attemptId} recorded`); + return attempt; +} + +describe("attempt identity", () => { + it("hands out monotonic, session-scoped ids that are never reused", () => { + const first = newAttemptId("s1"); + const second = newAttemptId("s1"); + const otherSession = newAttemptId("s2"); + expect(first.startsWith("s1:")).toBe(true); + expect(second.startsWith("s1:")).toBe(true); + expect(otherSession.startsWith("s2:")).toBe(true); + expect(new Set([first, second, otherSession]).size).toBe(3); + expect(second > first).toBe(true); + }); +}); + +describe("bound results", () => { + it("the newest attempt's bound result is the displayed result", () => { + let session = createSession("s", "walk", SHA); + const attemptId = newAttemptId("s"); + session = startAttemptAt(session, attemptId, SHA, 1_000); + session = applyOutcome(session, attemptId, resultOutcome(attemptId, SHA, SHA)); + expect(attemptOf(session, attemptId).status).toBe("bound"); + expect(session.currentResult?.stats.input_sha256).toBe(SHA); + expect(session.witnesses).toEqual([]); + }); +}); + +describe("stale outcomes", () => { + it("a late outcome for a superseded attempt is witnessed, not applied", () => { + let session = createSession("s", "walk", SHA); + const first = newAttemptId("s"); + const second = newAttemptId("s"); + session = startAttemptAt(session, first, SHA, 1_000); + // A retry with re-edited scene bytes supersedes the first attempt. + const editedSha = "ee".repeat(32); + session = startAttemptAt(session, second, editedSha, 1_010); + expect(attemptOf(session, first).status).toBe("stale"); // demoted by the new attempt + + session = applyOutcome(session, first, resultOutcome(first, SHA, SHA)); + const attempt = attemptOf(session, first); + expect(attempt.status).toBe("stale"); + expect(attempt.outcome?.type).toBe("result"); // recorded in history with its own hash + expect(session.witnesses).toEqual([ + { kind: "stale_outcome", attemptId: first, newestAttemptId: second }, + ]); + expect(session.currentResult).toBeNull(); + }); + + it("a stale arrival does not strip the newest attempt's bound result", () => { + let session = createSession("s", "walk", SHA); + const first = newAttemptId("s"); + const second = newAttemptId("s"); + session = startAttemptAt(session, first, SHA, 1_000); + session = applyOutcome(session, first, resultOutcome(first, SHA, SHA)); + const editedSha = "ee".repeat(32); + session = startAttemptAt(session, second, editedSha, 1_010); + session = applyOutcome(session, second, resultOutcome(second, editedSha, editedSha)); + expect(session.currentResult?.stats.input_sha256).toBe(editedSha); + + session = applyOutcome(session, first, resultOutcome(first, SHA, SHA)); + expect(session.witnesses).toEqual([ + { kind: "stale_outcome", attemptId: first, newestAttemptId: second }, + ]); + expect(session.currentResult?.stats.input_sha256).toBe(editedSha); + }); +}); + +describe("mismatched results", () => { + it("a result naming other bytes is a witness, never the displayed result", () => { + let session = createSession("s", "walk", SHA); + const attemptId = newAttemptId("s"); + session = startAttemptAt(session, attemptId, SHA, 1_000); + session = applyOutcome(session, attemptId, resultOutcome(attemptId, SHA, MISMATCHED_SHA256)); + const attempt = attemptOf(session, attemptId); + expect(attempt.status).toBe("mismatched"); + expect(attempt.outcome?.type).toBe("result"); // the answer is kept in the history + expect(session.witnesses).toEqual([ + { kind: "mismatched_result", attemptId, requestSha256: SHA, resultSha256: MISMATCHED_SHA256 }, + ]); + expect(session.currentResult).toBeNull(); + }); +}); + +describe("unparseable results", () => { + it("a 200 whose body is not a result is witnessed as such, never displayed", () => { + let session = createSession("s", "walk", SHA); + const attemptId = newAttemptId("s"); + session = startAttemptAt(session, attemptId, SHA, 1_000); + const outcome = outcomeFromBody(responseReceipt(attemptId), { hello: "world" }, SHA); + expect(outcome.type).toBe("unparseable_result"); + session = applyOutcome(session, attemptId, outcome); + expect(attemptOf(session, attemptId).status).toBe("failed"); + expect(session.witnesses).toEqual([ + { kind: "unparseable_result", attemptId, reason: "wrong_version" }, + ]); + expect(session.currentResult).toBeNull(); + }); + + it("a 200 with no body at all is witnessed as missing, never displayed", () => { + let session = createSession("s", "walk", SHA); + const attemptId = newAttemptId("s"); + session = startAttemptAt(session, attemptId, SHA, 1_000); + const outcome = outcomeFromBody(responseReceipt(attemptId), null, SHA); + expect(outcome.type).toBe("unparseable_result"); + session = applyOutcome(session, attemptId, outcome); + expect(session.witnesses).toEqual([ + { kind: "unparseable_result", attemptId, reason: "not_object" }, + ]); + expect(session.currentResult).toBeNull(); + }); +}); + +describe("refusals, faults and cancellations", () => { + it("a refused attempt records the envelope code and strips the display", () => { + let session = createSession("s", "walk", SHA); + const attemptId = newAttemptId("s"); + session = startAttemptAt(session, attemptId, SHA, 1_000); + const failure: PlacementFailure = { + kind: "server_error", + status: 422, + envelope: { + code: "invalid_scene", + message: "The scene has no meter object.", + path: "/meter", + }, + knownCode: true, + }; + session = applyOutcome(session, attemptId, { + type: "refused", + response: responseReceipt(attemptId), + failure, + }); + expect(attemptOf(session, attemptId).status).toBe("refused"); + expect(session.witnesses).toEqual([ + { + kind: "refused", + attemptId, + code: "invalid_scene", + detail: "The scene has no meter object.", + }, + ]); + expect(session.currentResult).toBeNull(); + }); + + it("a refusal after a bound attempt keeps the history and strips the display", () => { + let session = createSession("s", "walk", SHA); + const first = newAttemptId("s"); + const second = newAttemptId("s"); + session = startAttemptAt(session, first, SHA, 1_000); + session = applyOutcome(session, first, resultOutcome(first, SHA, SHA)); + expect(session.currentResult).not.toBeNull(); + + session = startAttemptAt(session, second, SHA, 1_010); + expect(session.currentResult).toBeNull(); // a new attempt starts with no answer shown + const failure: PlacementFailure = { kind: "network_error", detail: "connection reset" }; + session = applyOutcome(session, second, { type: "failed", failure }); + expect(session.currentResult).toBeNull(); + expect(session.attempts).toHaveLength(2); + expect(attemptOf(session, first).outcome?.type).toBe("result"); // history intact + }); + + it("cancellation records the state but no witness", () => { + let session = createSession("s", "walk", SHA); + const attemptId = newAttemptId("s"); + session = startAttemptAt(session, attemptId, SHA, 1_000); + session = applyOutcome(session, attemptId, { type: "cancelled" }); + expect(attemptOf(session, attemptId).status).toBe("cancelled"); + expect(session.witnesses).toEqual([]); + expect(session.currentResult).toBeNull(); + }); +}); + +describe("result intake witnesses", () => { + const bodyOf = () => JSON.parse(JSON.stringify(resultFixture(SHA))) as Record; + + it.each([null, 42, "result", []])("refuses a body that is not a JSON object (%j)", (value) => { + const intake = intakeResultBody(value); + expect(intake.ok).toBe(false); + if (!intake.ok) { + expect(intake.failure.reason).toBe("not_object"); + } + }); + + it("refuses a wrong schema_version and names both versions", () => { + const body = bodyOf(); + body.schema_version = "2.0"; + const intake = intakeResultBody(body); + expect(intake.ok).toBe(false); + if (!intake.ok) { + expect(intake.failure.reason).toBe("wrong_version"); + expect(intake.failure.detail).toContain("2.0"); + expect(intake.failure.detail).toContain("1.0"); + } + }); + + it("refuses a result missing any required field, naming it", () => { + for (const field of REQUIRED_RESULT_FIELDS) { + // A missing schema_version is a wrong version, not a missing field; covered above. + if (field === "schema_version") continue; + const stripped = bodyOf(); + Reflect.deleteProperty(stripped, field); + const intake = intakeResultBody(stripped); + expect(intake.ok).toBe(false); + if (!intake.ok) { + expect(intake.failure.reason).toBe("missing_required"); + expect(intake.failure.detail).toContain(field); + } + } + }); + + it("refuses a result whose input_sha256 cannot bind the answer to an upload", () => { + const body = bodyOf(); + const stats = body.stats as Record; + stats.input_sha256 = "nothex"; + const intake = intakeResultBody(body); + expect(intake.ok).toBe(false); + if (!intake.ok) { + expect(intake.failure.reason).toBe("wrong_shape"); + expect(intake.failure.detail).toContain("SHA-256"); + } + }); + + it("refuses a decision outside the contract", () => { + const body = bodyOf(); + body.decision = "maybe"; + const intake = intakeResultBody(body); + expect(intake.ok).toBe(false); + if (!intake.ok) { + expect(intake.failure.reason).toBe("wrong_shape"); + expect(intake.failure.detail).toContain("decision"); + } + }); + + it("notes older-server absences instead of calling them damage", () => { + const stripped = bodyOf(); + const policy = stripped.policy as Record; + Reflect.deleteProperty(policy, "allow_reject"); + Reflect.deleteProperty(policy, "notice"); + Reflect.deleteProperty(stripped, "nearest_considered"); + Reflect.deleteProperty(stripped, "objects_not_used"); + const route = stripped.route as Record; + Reflect.deleteProperty(route, "length_is_lower_bound"); + const intake = intakeResultBody(stripped); + expect(intake.ok).toBe(true); + if (!intake.ok) return; + expect(intake.additiveNotes).toHaveLength(5); + const notes = intake.additiveNotes.join("\n"); + expect(notes).toContain("allow_reject"); + expect(notes).toContain("notice"); + expect(notes).toContain("nearest_considered"); + expect(notes).toContain("objects_not_used"); + expect(notes).toContain("length_is_lower_bound"); + }); + + it("binding compares the request hash with the result's own report", () => { + const parsed = intakeResultBody(bodyOf()); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(bindResultToRequest(parsed.result, SHA)).toEqual({ kind: "bound" }); + expect(bindResultToRequest(parsed.result, MISMATCHED_SHA256)).toEqual({ + kind: "mismatched", + requestSha256: MISMATCHED_SHA256, + resultSha256: SHA, + }); + }); +}); diff --git a/web/src/lib/contract/session.ts b/web/src/lib/contract/session.ts new file mode 100644 index 000000000..8a1ce0883 --- /dev/null +++ b/web/src/lib/contract/session.ts @@ -0,0 +1,293 @@ +// Session identity and outcome association, pure: no DOM, no storage, no fetch — the +// store and page layers (and their tests) build on this. A session is a named container +// for one scene's inspection life; each submission attempt is identified by an explicit +// attempt id and the SHA-256 of the exact bytes it sent. Three witnesses are handled +// here and tested below-by-contract: +// +// - stale: an outcome for a superseded attempt (a retry happened, or a duplicate +// response landed late) is recorded but never applied over newer state — the HLD's +// "validate session and input revision before applying it; never overwrite newer +// state blindly". +// - mismatched: a result whose stats.input_sha256 names different bytes than the +// request sent is not this submission's answer. Recorded as a witness, never shown. +// - missing: a 200 whose body is not a parseable result (or the response never parses) +// is recorded as such, again never shown as an answer. +// +// A refused, failed or cancelled attempt also strips the displayed result: a partial or +// refused round of processing must not leave yesterday's answer presented as current. +// The previous answer stays in the attempt history, labelled with its own hash. + +import type { PlacementFailure } from "./errors.ts"; +import { + bindResultToRequest, + intakeResultBody, + type ResultBinding, + type ResultIntakeFailure, +} from "./result.ts"; +import type { PlacementResult } from "./types.ts"; + +export type SubmissionStatus = + | "submitting" + | "bound" + /** The server answered 200 with a parseable result that names different bytes. */ + | "mismatched" + | "refused" + | "failed" + | "cancelled" + | "stale"; + +/** Bounded receipt of what was actually sent, for the committed evidence trail. */ +export interface RequestReceipt { + readonly attemptId: string; + readonly url: string; + readonly method: string; + readonly contentType: string; + readonly byteLength: number; + readonly requestSha256: string; + readonly startedAt: number; +} + +/** Bounded receipt of what actually came back (body bytes capped by the client). */ +export interface ResponseReceipt { + readonly attemptId: string; + readonly status: number; + readonly contentType: string; + readonly bodyByteLength: number; + readonly bodySha256: string | null; + readonly bodyExcerpt: string | null; + /** True when the body exceeded the client's cap and was not read to the end. */ + readonly bodyTruncated: boolean; + readonly elapsedMs: number; + readonly finishedAt: number; +} + +export type AttemptOutcome = + | { + readonly type: "result"; + readonly response: ResponseReceipt; + readonly result: PlacementResult; + readonly binding: ResultBinding; + readonly additiveNotes: readonly string[]; + } + | { + readonly type: "unparseable_result"; + readonly response: ResponseReceipt; + readonly failure: ResultIntakeFailure; + } + | { + readonly type: "refused"; + readonly response: ResponseReceipt; + readonly failure: PlacementFailure; + } + | { readonly type: "failed"; readonly failure: PlacementFailure } + | { readonly type: "cancelled" }; + +export interface SubmissionAttempt { + readonly attemptId: string; + readonly startedAt: number; + readonly requestSha256: string; + readonly request: RequestReceipt; + readonly status: SubmissionStatus; + readonly outcome: AttemptOutcome | null; +} + +export interface SessionRecord { + readonly sessionId: string; + readonly label: string; + readonly createdAt: number; + readonly sceneSha256: string; + readonly attempts: readonly SubmissionAttempt[]; + /** + * The one result currently presented, or null. Derived once here so every caller + * agrees: the result of the newest attempt, and only when that attempt is bound. + */ + readonly currentResult: PlacementResult | null; + /** Witnesses observed on this session: mismatches, stale arrivals, unparseable bodies. */ + readonly witnesses: readonly SessionWitness[]; +} + +export type SessionWitness = + | { + readonly kind: "mismatched_result"; + readonly attemptId: string; + readonly requestSha256: string; + readonly resultSha256: string; + } + | { readonly kind: "stale_outcome"; readonly attemptId: string; readonly newestAttemptId: string } + | { readonly kind: "unparseable_result"; readonly attemptId: string; readonly reason: string } + | { + readonly kind: "refused"; + readonly attemptId: string; + readonly code: string; + readonly detail: string; + }; + +let attemptCounter = 0; + +/** Explicit attempt identity: monotonic, session-scoped, never reused. */ +export function newAttemptId(sessionId: string): string { + attemptCounter += 1; + return `${sessionId}:attempt-${attemptCounter.toString(36).padStart(4, "0")}`; +} + +export function createSession( + sessionId: string, + label: string, + sceneSha256: string, +): SessionRecord { + return { + sessionId, + label, + createdAt: Date.now(), + sceneSha256, + attempts: [], + currentResult: null, + witnesses: [], + }; +} + +export function startAttempt( + session: SessionRecord, + attemptId: string, + request: RequestReceipt, + now: number = Date.now(), +): SessionRecord { + // Starting any attempt demotes every older one: only the newest submission is current. + const demoted = session.attempts.map((attempt) => + attempt.status === "submitting" + ? { ...attempt, status: "stale" as const, outcome: attempt.outcome } + : attempt, + ); + return { + ...session, + attempts: [ + ...demoted, + { + attemptId, + startedAt: now, + requestSha256: request.requestSha256, + request, + status: "submitting", + outcome: null, + }, + ], + currentResult: null, + }; +} + +function newestAttemptId(session: SessionRecord): string | null { + return session.attempts.length > 0 + ? (session.attempts[session.attempts.length - 1]?.attemptId ?? null) + : null; +} + +/** + * Applies an outcome to one attempt. Outcomes for superseded attempts are witnessed as + * stale and left unapplied. Only a bound result of the newest attempt becomes the + * displayed result; a mismatched result is a witness, never a display. + */ +export function applyOutcome( + session: SessionRecord, + attemptId: string, + outcome: AttemptOutcome, +): SessionRecord { + const newest = newestAttemptId(session); + const isStaleArrival = attemptId !== newest; + + const witness: SessionWitness | null = (() => { + if (isStaleArrival && outcome.type !== "cancelled") { + return { kind: "stale_outcome", attemptId, newestAttemptId: newest ?? "" }; + } + if (outcome.type === "result" && outcome.binding.kind === "mismatched") { + return { + kind: "mismatched_result", + attemptId, + requestSha256: outcome.binding.requestSha256, + resultSha256: outcome.binding.resultSha256, + }; + } + if (outcome.type === "unparseable_result") { + return { kind: "unparseable_result", attemptId, reason: outcome.failure.reason }; + } + if (outcome.type === "refused") { + return { + kind: "refused", + attemptId, + code: + outcome.failure.kind === "server_error" + ? outcome.failure.envelope.code + : outcome.failure.kind, + detail: + outcome.failure.kind === "server_error" + ? outcome.failure.envelope.message + : "The server did not answer with the contract's envelope.", + }; + } + return null; + })(); + + const attempts = session.attempts.map((attempt) => { + if (attempt.attemptId !== attemptId) { + return attempt; + } + if (isStaleArrival && outcome.type !== "cancelled") { + if (attempt.status === "submitting") { + return { ...attempt, status: "stale" as const, outcome }; + } + // Already stale: keep that status, but still record the late outcome in history. + return attempt.status === "stale" ? { ...attempt, outcome } : attempt; + } + switch (outcome.type) { + case "result": + // "bound" only when the result answers the exact bytes this attempt sent; a + // mismatched answer is a witness (recorded above), never the displayed result. + return { + ...attempt, + status: outcome.binding.kind === "bound" ? ("bound" as const) : ("mismatched" as const), + outcome, + }; + case "unparseable_result": + return { ...attempt, status: "failed" as const, outcome }; + case "refused": + return { ...attempt, status: "refused" as const, outcome }; + case "failed": + return { ...attempt, status: "failed" as const, outcome }; + case "cancelled": + return { ...attempt, status: "cancelled" as const, outcome }; + default: + return attempt; + } + }); + + const current = attempts[attempts.length - 1]; + const updated: SessionRecord = { + ...session, + attempts, + currentResult: + current !== undefined && current.status === "bound" && current.outcome?.type === "result" + ? current.outcome.result + : null, + witnesses: witness === null ? session.witnesses : [...session.witnesses, witness], + }; + return updated; +} + +/** Convenience used by the client layer: parse a body and produce the matching outcome. */ +export function outcomeFromBody( + response: ResponseReceipt, + body: unknown, + requestSha256: string, +): AttemptOutcome { + const intake = intakeResultBody(body); + if (!intake.ok) { + return { type: "unparseable_result", response, failure: intake.failure }; + } + const binding = bindResultToRequest(intake.result, requestSha256); + return { + type: "result", + response, + result: intake.result, + binding, + additiveNotes: intake.additiveNotes, + }; +} diff --git a/web/src/lib/contract/types.ts b/web/src/lib/contract/types.ts new file mode 100644 index 000000000..407d289ee --- /dev/null +++ b/web/src/lib/contract/types.ts @@ -0,0 +1,244 @@ +// TypeScript mirrors of the placement contract: server/schemas/scene.schema.json and +// server/schemas/result.schema.json. The schemas are the authority; tests in this folder +// load the real schema files and check this mirror against them (schema-conformance +// tests), so a schema change that matters fails a test instead of drifting silently. +// +// Lengths are feet everywhere, matching the schema. Plan coordinates are [x, z]. + +/** [x, z] plan point in feet (scene frame, ARKit .gravity world alignment). */ +export type Point2 = [number, number]; + +/** [start, end] in s: feet along the wall chain from the meter, start <= end. */ +export type Span = [number, number]; + +export type WallSource = "tap" | "mesh" | "plane"; +export type ObjectSource = "vlm" | "tap" | "tape"; +export type ObjectKind = + | "window" + | "door" + | "garage_door" + | "ac" + | "gas_meter" + | "elec_box" + | "vent" + | "downspout" + | "pool" + | "battery"; +export type GroundKind = "drive" | "concrete" | "gravel" | "lawn" | "mulch" | "deck"; +export type CoverageBand = "wall" | "ground" | "overhead" | "facing"; +export type EndKind = "limit" | "unexplored"; + +export interface SceneWall { + readonly id: string; + readonly baseline: readonly Point2[]; + readonly height_ft?: number; + readonly source?: WallSource; + readonly plus_minus_ft?: number; +} + +export interface SceneObject { + readonly type: ObjectKind; + readonly wall_id: string; + readonly span_ft: Span; + readonly bottom_ft?: number; + readonly top_ft?: number; + readonly attrs?: { readonly operable?: boolean; readonly well?: boolean }; + readonly source: ObjectSource; + readonly conf?: number; + readonly plus_minus_ft?: number; + readonly footprint?: readonly Point2[]; +} + +export interface SceneGroundPatch { + readonly type: GroundKind; + readonly polygon: readonly Point2[]; + readonly plus_minus_ft?: number; +} + +export interface SceneOverhead { + readonly wall_id: string; + readonly span_ft: Span; + readonly clearance_ft: number; + readonly plus_minus_ft?: number; +} + +export interface SceneFacing { + readonly wall_id: string; + readonly span_ft: Span; + readonly depth_ft: number; + readonly plus_minus_ft?: number; +} + +export interface CoverageObserved { + readonly band: CoverageBand; + readonly span_ft: Span; + readonly out_ft?: number; + readonly camera_pos_ft?: readonly [number, number]; +} + +export interface SceneCoverage { + readonly ends?: { + readonly left?: { readonly kind?: EndKind; readonly note?: string }; + readonly right?: { readonly kind?: EndKind; readonly note?: string }; + }; + readonly observed?: readonly CoverageObserved[]; +} + +export interface Scene { + readonly schema_version?: string; + readonly meter: { + readonly pos: readonly [number, number, number]; + readonly wall_id: string; + readonly plus_minus_ft?: number; + }; + readonly walls: readonly SceneWall[]; + readonly objects?: readonly SceneObject[]; + readonly ground?: readonly SceneGroundPatch[]; + readonly overheads?: readonly SceneOverhead[]; + readonly facing?: readonly SceneFacing[]; + readonly coverage?: SceneCoverage; + readonly keyframes?: readonly { + readonly id: string; + readonly img: string; + }[]; + readonly stills?: Readonly>; + readonly gps?: { readonly lat: number; readonly lon: number }; + readonly heading?: { readonly deg: number; readonly accuracy_deg?: number }; +} + +export type Outcome = "pass" | "fail" | "unsure"; +export type Decision = "pass" | "manual_review" | "reject"; +export type UnsureCause = "margin" | "unobserved" | "unknown_attribute" | "rule_requires_review"; + +export interface RuleCitation { + /** Parameter name in rules.yaml. */ + readonly key: string; + /** Citation for the value (public code or "Private rules"). */ + readonly source: string; + /** True for a demo value with no public source. */ + readonly placeholder: boolean; +} + +export interface ResultCheck { + readonly id: string; + readonly label: string; + readonly outcome: Outcome; + readonly unsure_cause?: UnsureCause; + readonly reason: string; + readonly measured_ft: number | null; + readonly plus_minus_ft: number | null; + readonly threshold_ft: number | null; + readonly review_threshold_ft?: number; + readonly comparison: "at_least" | "at_most" | null; + readonly subject: string | null; + readonly rule: RuleCitation; +} + +export interface ResultSpot { + readonly outcome: Outcome; + readonly wall_id: string; + readonly segment: number; + readonly span_ft: Span; + readonly width_ft: number; + readonly depth_ft: number; + readonly height_ft: number; + readonly footprint: readonly [Point2, Point2, Point2, Point2]; + readonly center: Point2; + readonly along: Point2; + readonly outward: Point2; + readonly meter_offset_ft: readonly [number, number]; + readonly route_length_ft: number | null; +} + +export interface ResultRoute { + readonly outcome: Outcome; + readonly length_ft: number; + readonly plus_minus_ft: number; + readonly height_ft: number; + readonly polyline: readonly Point2[]; + readonly detours?: readonly { readonly subject: string; readonly extra_ft: number }[]; + readonly length_is_lower_bound?: boolean; + readonly crossings?: readonly { + readonly subject: string; + readonly span_ft: Span; + readonly effect: "fail" | "review" | "detour" | "allow"; + }[]; +} + +export interface MissingEvidence { + readonly kind: "band" | "past_end"; + readonly band?: CoverageBand; + readonly span_ft?: Span; + readonly out_ft?: number; + readonly side?: "left" | "right"; + readonly checks?: readonly string[]; + readonly message: string; +} + +export type ReasonCode = + | "all_checks_pass" + | "policy_not_approved" + | "unsure_checks" + | "unobserved_area" + | "unexplored_end" + | "all_spots_fail" + | "no_wall_segment_fits"; + +export interface ResultReason { + readonly code: ReasonCode; + readonly message: string; + readonly checks?: readonly string[]; +} + +export interface ResultEnd { + readonly kind: EndKind; + readonly s_ft: number; + readonly point: Point2; + readonly beyond_reach?: boolean; +} + +export interface SweepRun { + readonly wall_id: string; + readonly segment?: number; + readonly start_ft: Span; + readonly outcome: Outcome; + readonly failing: readonly string[]; + readonly unsure: readonly string[]; +} + +export interface PlacementResult { + readonly schema_version: "1.0"; + readonly decision: Decision; + readonly summary: string; + readonly reasons: readonly ResultReason[]; + readonly policy: { + readonly id: string | null; + readonly version: string | null; + readonly auto_approve: boolean; + readonly allow_reject?: boolean; + readonly sources: readonly ("public" | "private")[]; + readonly rules_sha256: string; + readonly notice: string | null; + }; + readonly spot: ResultSpot | null; + readonly route: ResultRoute | null; + readonly checks: readonly ResultCheck[]; + readonly nearest_considered?: ResultSpot | null; + readonly missing_evidence: readonly MissingEvidence[]; + readonly objects_not_used?: readonly { + readonly object: string; + readonly side: "left" | "right"; + readonly message: string; + }[]; + readonly ends: { readonly left: ResultEnd; readonly right: ResultEnd }; + readonly sweep: readonly SweepRun[]; + readonly stats: { + readonly candidates: number; + readonly pass: number; + readonly unsure: number; + readonly fail: number; + readonly elapsed_ms: number; + /** SHA-256 of the scene JSON bytes exactly as uploaded — the response-to-request binding. */ + readonly input_sha256: string; + }; +} diff --git a/web/src/lib/contract/versions.ts b/web/src/lib/contract/versions.ts new file mode 100644 index 000000000..a07f36366 --- /dev/null +++ b/web/src/lib/contract/versions.ts @@ -0,0 +1,46 @@ +// Which schema versions this client build understands. The server publishes the real +// schemas (GET /v1/schemas/scene.json and /v1/schemas/result.json); this module only +// decides whether a received version is supported, compatible, or refused. +// +// scene.schema.json: "Contract version. Absent means \"1.0\". Minor versions only add +// optional fields." So any 1.x scene is parseable; a minor above the tested one is +// accepted but must be reported as not verified by this build (the export names it). +// result.schema.json pins schema_version to "1.0", so a result with any other version +// is refused, not guessed at. + +/** The scene minor version this build's tests cover. */ +export const TESTED_SCENE_VERSION = "1.0"; + +/** The only result version the contract defines. */ +export const RESULT_VERSION = "1.0"; + +export type SceneVersionReport = + | { kind: "supported"; version: string } + | { kind: "compatible_untested_minor"; version: string; testedVersion: string } + | { kind: "unsupported"; version: string }; + +const SCENE_VERSION_PATTERN = /^1\.[0-9]+$/; + +/** + * Classifies a scene's schema_version the way the schema defines it: absent means + * "1.0"; anything outside the 1.x pattern or above the tested minor is reported, + * never silently accepted or silently dropped. + */ +export function reportSceneVersion(schemaVersion: unknown): SceneVersionReport { + const version = + typeof schemaVersion === "string" && schemaVersion !== "" + ? schemaVersion + : TESTED_SCENE_VERSION; + if (!SCENE_VERSION_PATTERN.test(version)) { + return { kind: "unsupported", version }; + } + if (version === TESTED_SCENE_VERSION) { + return { kind: "supported", version }; + } + return { kind: "compatible_untested_minor", version, testedVersion: TESTED_SCENE_VERSION }; +} + +/** A result must carry exactly the one version the contract defines. */ +export function reportResultVersion(schemaVersion: unknown): { ok: boolean; version: unknown } { + return { ok: schemaVersion === RESULT_VERSION, version: schemaVersion }; +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index 1f46905e1..5ab6da4aa 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -4,7 +4,7 @@ "target": "es2023", "lib": ["ES2023", "DOM", "DOM.Iterable"], "module": "esnext", - "types": ["vite/client"], + "types": ["vite/client", "node"], "skipLibCheck": true, "moduleResolution": "bundler", From 07c681bdd8b112136364419a0f78fd5f301dbc72 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:49:28 +0000 Subject: [PATCH 2/4] feat(web): bind the placement session into the app with live status and receipts Session selection/submission/cancel with receipts (M2): - SessionStore owns the SessionRecord: scene intake (exact bytes + SHA-256), submit via PlacementClient with an AbortSignal, outcome binding to the newest attempt, and cancellation mid-flight that records an aborted outcome while keeping the request receipt. - App wiring: server URL field (default http://localhost:8000), in-memory bearer token that is never persisted or logged, file picker, live status line, cancel button, and an outcome panel that displays only the newest attempt's bound outcome; stale answers stay in history as witnesses and are never displayed as current. - Fix PlacementClient to invoke the provided fetch without a bound receiver: Chromium's native fetch throws 'Illegal invocation' when called on the client instance, which fake-based tests could not see. Regression test covers receiver-free invocation. - 107/107 vitest, biome, tsc, production build; real-browser roundtrip verified against a loopback harness (submit to bound, cancel, retry, 422 refusal with server message and path, keyboard Enter activation, 390x844 without overflow). Co-authored-by: obvious-autobuild[bot] <262744130+obvious-autobuild[bot]@users.noreply.github.com> --- web/README.md | 8 +- web/src/App.tsx | 48 ++++ web/src/components/OutcomePanel.tsx | 240 +++++++++++++++++++ web/src/components/ScenePicker.tsx | 80 +++++++ web/src/components/ServerFields.tsx | 53 +++++ web/src/lib/contract/client.test.ts | 30 +++ web/src/lib/contract/client.ts | 21 +- web/src/lib/session/store.test.ts | 321 +++++++++++++++++++++++++ web/src/lib/session/store.ts | 355 ++++++++++++++++++++++++++++ 9 files changed, 1150 insertions(+), 6 deletions(-) create mode 100644 web/src/components/OutcomePanel.tsx create mode 100644 web/src/components/ScenePicker.tsx create mode 100644 web/src/components/ServerFields.tsx create mode 100644 web/src/lib/session/store.test.ts create mode 100644 web/src/lib/session/store.ts diff --git a/web/README.md b/web/README.md index 9ede33fb9..a5cf7edb0 100644 --- a/web/README.md +++ b/web/README.md @@ -1,6 +1,12 @@ # web -Browser app for zero-install capture experiments. Right now it holds a placeholder page and `src/lib/units.ts`, which converts meters (what ARKit reports) to feet and inches for display. +Browser app for zero-install capture experiments. The home page runs a placement +session: pick a `scene.json`, aim it at a placement server, run the attempt (with a +live status line and cancellation), and read the answer. The state layer lives in +`src/lib/session/store.ts` on top of the placement contract in `src/lib/contract/`; +the contract's test double (`contract-fake.ts`) is also the only server surface the +tests use. The optional bearer token stays in memory for the next submission only — +it is never persisted and never logged. ## Setup diff --git a/web/src/App.tsx b/web/src/App.tsx index d0ba8ff97..503ace666 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,8 +1,56 @@ +// The placement session page. All state lives in the session store (one SessionRecord +// at a time); React subscribes through useSyncExternalStore and renders the store's +// snapshot. The page owns exactly the M2 controls: server fields, the scene picker, +// the attempt status line, the run/cancel buttons, and the outcome panel that can +// only ever show the newest attempt's bound outcome or refusal. + +import { useCallback, useRef, useSyncExternalStore } from "react"; +import { OutcomePanel } from "./components/OutcomePanel.tsx"; +import { ScenePicker } from "./components/ScenePicker.tsx"; +import { ServerFields } from "./components/ServerFields.tsx"; +import { SessionStore, statusLine } from "./lib/session/store.ts"; + export function App() { + const storeRef = useRef(null); + if (storeRef.current === null) { + storeRef.current = new SessionStore(); + } + const store = storeRef.current; + const subscribe = useCallback((listener: () => void) => store.subscribe(listener), [store]); + const getSnapshot = useCallback(() => store.getSnapshot(), [store]); + const state = useSyncExternalStore(subscribe, getSnapshot); + + const submitting = state.display.kind === "submitting"; + const canSubmit = state.scene !== null && !submitting; + return (

House Scan: web capture

Browser capture experiments live here.

+
+

Placement session

+ store.setBaseUrl(url)} + onTokenChange={(token) => store.setToken(token)} + /> + void store.selectSceneFile(file)} + /> +

{statusLine(state.display)}

+

+ {" "} + +

+ +
); } diff --git a/web/src/components/OutcomePanel.tsx b/web/src/components/OutcomePanel.tsx new file mode 100644 index 000000000..de9931de5 --- /dev/null +++ b/web/src/components/OutcomePanel.tsx @@ -0,0 +1,240 @@ +// The outcome panel: renders exactly what the store's displayed view carries. It is +// never handed an attempt list, so it cannot show an older attempt's answer even by +// accident — the store's displayedView already restricted the view to the newest +// attempt, and the result in it only when that attempt is bound. A mismatched answer +// shows as the fact of a mismatch (hashes, witness note), never as a result; a +// refusal shows the server's own message with the JSON pointer of the field at fault. + +import { failureHeadline } from "../lib/contract/errors.ts"; +import type { PlacementResult } from "../lib/contract/types.ts"; +import type { DisplayView } from "../lib/session/store.ts"; + +function ft(n: number): string { + return `${n} ft`; +} + +function SpanText({ span }: { readonly span: readonly [number, number] }) { + return ( + <> + {ft(span[0])} to {ft(span[1])} + + ); +} + +function BoundResult({ view }: { view: Extract }) { + const result = view.result; + return ; +} + +function BoundResultBody({ + result, + additiveNotes, +}: { + result: PlacementResult; + additiveNotes: readonly string[]; +}) { + return ( +
+

+ Decision: {result.decision} +

+

{result.summary}

+

Reasons

+
    + {result.reasons.map((reason) => ( +
  • + {reason.code}: {reason.message} +
  • + ))} +
+

Spot

+ {result.spot === null ? ( +

No spot fits the observed evidence.

+ ) : ( +

+ Wall {result.spot.wall_id} segment {result.spot.segment}, span{" "} + , width {ft(result.spot.width_ft)}, depth{" "} + {ft(result.spot.depth_ft)}, height {ft(result.spot.height_ft)}, center ( + {result.spot.center[0]}, {result.spot.center[1]}), outcome{" "} + {result.spot.outcome}. +

+ )} +

Route

+ {result.route === null ? ( +

No route was produced.

+ ) : ( +

+ Length {ft(result.route.length_ft)} plus or minus {ft(result.route.plus_minus_ft)}, height{" "} + {ft(result.route.height_ft)}, outcome {result.route.outcome} + {result.route.length_is_lower_bound === true + ? " (a lower bound; the true length may be longer)" + : null} + . +

+ )} +

Checks

+ {result.checks.length === 0 ? ( +

No checks ran.

+ ) : ( +
    + {result.checks.map((check) => ( +
  • + {check.label}: {check.outcome}. {check.reason} + {check.measured_ft === null || check.threshold_ft === null + ? null + : check.comparison === null + ? "" + : check.comparison === "at_least" + ? ` Measured ${ft(check.measured_ft)} against a minimum of ${ft(check.threshold_ft)}.` + : ` Measured ${ft(check.measured_ft)} against a maximum of ${ft(check.threshold_ft)}.`} +
  • + ))} +
+ )} +

Policy

+

+ {result.policy.id === null ? "No named policy" : {result.policy.id}} + {result.policy.version === null ? null : <> version {result.policy.version}}, sources{" "} + {result.policy.sources.join(", ")}, rules {result.policy.rules_sha256}. + {result.policy.notice === null ? null : <> {result.policy.notice}} +

+ {result.missing_evidence.length === 0 ? null : ( + <> +

Missing evidence

+
    + {result.missing_evidence.map((missing) => ( +
  • {missing.message}
  • + ))} +
+ + )} + {additiveNotes.length === 0 ? null : ( + <> +

Notes on this answer

+
    + {additiveNotes.map((note) => ( +
  • {note}
  • + ))} +
+ + )} +

Statistics

+

+ {result.stats.candidates} candidate{result.stats.candidates === 1 ? "" : "s"}:{" "} + {result.stats.pass} pass, {result.stats.unsure} unsure, {result.stats.fail} fail, in{" "} + {result.stats.elapsed_ms} ms. +

+

+ Bound to upload {result.stats.input_sha256} +

+
+ ); +} + +export function OutcomePanel({ display }: { display: DisplayView }) { + switch (display.kind) { + case "no_scene": + return ( +
+

Outcome

+

Select a scene.json file to begin a session.

+
+ ); + case "scene_ready": + return ( +
+

Outcome

+

The scene is ready. No attempts have run yet.

+
+ ); + case "submitting": + return ( +
+

Outcome

+

The attempt is in flight. Cancelling aborts the request.

+
+ ); + case "bound": + return ( +
+

Result

+ +
+ ); + case "mismatched": + return ( +
+

Mismatched answer

+

+ The server answered 200 with a result that names different scene bytes. It is recorded + on the attempt as a witness, and is not displayed as this submission's answer. +

+

+ The request sent {display.requestSha256}; the answer claims{" "} + {display.resultSha256}. +

+
+ ); + case "refused": { + const failure = display.failure; + return ( +
+

Refusal

+

{failureHeadline(failure)}

+ {failure.kind === "server_error" ? ( +

+ The server said ({failure.envelope.code}, HTTP {failure.status}):{" "} + {failure.envelope.message} + {failure.envelope.path === null ? null : ( + <> + {" "} + At {failure.envelope.path}. + + )} +

+ ) : failure.kind === "unparseable_error" ? ( +

{failure.detail}

+ ) : null} +
+ ); + } + case "failed": { + const failure = display.failure; + return ( +
+

Failure

+

{failureHeadline(failure)}

+ {failure.kind === "network_error" ?

{failure.detail}

: null} +
+ ); + } + case "unparseable_result": + return ( +
+

Unreadable answer

+

+ The server answered 200 with something this client cannot read as a result. The body is + recorded on the attempt as a witness, and nothing is displayed as the answer. +

+

{display.detail}

+
+ ); + case "cancelled": + return ( +
+

Cancelled

+

+ The attempt was cancelled before an answer arrived. The request it sent stays in the + session's history with its receipt. +

+
+ ); + case "superseded": + return ( +
+

Superseded

+

This attempt was superseded; its record stays in the session's history.

+
+ ); + } +} diff --git a/web/src/components/ScenePicker.tsx b/web/src/components/ScenePicker.tsx new file mode 100644 index 000000000..85c7e9415 --- /dev/null +++ b/web/src/components/ScenePicker.tsx @@ -0,0 +1,80 @@ +// The scene picker: a file input whose selection the store intakes. The chosen file's +// exact text is what gets hashed and uploaded, so the summary here reports the intake +// facts (schema version, byte length, SHA-256) rather than re-serializing anything. +// A selection the client refuses locally is shown with the same code/message/path +// shape the server uses; it never clears a session already in progress. + +import type { SceneIntakeIssue } from "../lib/contract/scene.ts"; +import type { SceneFileInput, SceneSummary } from "../lib/session/store.ts"; + +function versionText(version: SceneSummary["version"]): string { + switch (version.kind) { + case "supported": + return `${version.version} (tested by this build)`; + case "compatible_untested_minor": + return `${version.version} (newer minor; this build tested ${version.testedVersion})`; + case "unsupported": + return `${version.version} (unsupported by this client)`; + } +} + +interface ScenePickerProps { + readonly scene: SceneSummary | null; + readonly intakeError: SceneIntakeIssue | null; + readonly onSelect: (file: SceneFileInput) => void; +} + +export function ScenePicker({ scene, intakeError, onSelect }: ScenePickerProps) { + return ( +
+ Scene +

+ +
+ { + const file = event.target.files?.[0]; + // Reset so picking the same file again still fires a change. + event.currentTarget.value = ""; + if (file !== undefined) { + onSelect(file); + } + }} + /> +

+ {intakeError === null ? null : ( +

+ The scene file was refused: {intakeError.message} + {intakeError.path === null ? null : ( + <> + {" "} + At {intakeError.path}. + + )}{" "} + ({intakeError.code}) +

+ )} + {scene === null ? null : ( +
+

+ Loaded {scene.fileName}, schema version {versionText(scene.version)},{" "} + {scene.byteLength} bytes. +

+

+ Upload SHA-256: {scene.sha256} +

+ {scene.warnings.length === 0 ? null : ( +
    + {scene.warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/web/src/components/ServerFields.tsx b/web/src/components/ServerFields.tsx new file mode 100644 index 000000000..1458ea84a --- /dev/null +++ b/web/src/components/ServerFields.tsx @@ -0,0 +1,53 @@ +// Server connection fields: where the placement server lives, and the optional bearer +// token for servers that loaded private rules. The token is an uncontrolled input: the +// store keeps it in memory for the next submission only, so nothing here can persist +// or log it, and no re-render ever needs to read it back. + +interface ServerFieldsProps { + readonly baseUrl: string; + readonly hasToken: boolean; + readonly onBaseUrlChange: (url: string) => void; + readonly onTokenChange: (token: string) => void; +} + +export function ServerFields({ + baseUrl, + hasToken, + onBaseUrlChange, + onTokenChange, +}: ServerFieldsProps) { + return ( +
+ Server +

+ +
+ onBaseUrlChange(event.target.value)} + /> +

+

+ +
+ onTokenChange(event.target.value)} + /> +

+

+ {hasToken + ? "A token is set. It stays in this page's memory only: never stored, never logged, gone when the page closes." + : "Optional: only servers that loaded private rules need a bearer token. If set, it stays in this page's memory only."} +

+
+ ); +} diff --git a/web/src/lib/contract/client.test.ts b/web/src/lib/contract/client.test.ts index a9e0acba3..0ba33a063 100644 --- a/web/src/lib/contract/client.test.ts +++ b/web/src/lib/contract/client.test.ts @@ -244,6 +244,36 @@ describe("transport faults and cancellation", () => { expect(report.exchange.request.requestSha256).toBe(run.requestSha256); }); + it("invokes a provided fetch without binding a receiver, as Chromium's native fetch demands", async () => { + // Native fetch throws "Illegal invocation" when called with anything but the + // global as its receiver. The client stores the provided function and must + // never turn its calls into member calls on the client itself. + const fake = createContractFake(); + const chromiumLike = function illegalUnlessReceiverFree( + this: unknown, + input: Parameters[0], + init?: Parameters[1], + ): Promise { + if (this !== undefined) { + return Promise.reject( + new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation"), + ); + } + return fake.fetch(input, init); + }; + const client = new PlacementClient({ + baseUrl: "http://localhost:8000", + fetchImpl: chromiumLike, + }); + const intake = await intakeSceneText(sceneText()); + const report = await client.submitPlacement( + intake, + newAttemptId("t"), + new AbortController().signal, + ); + expect(report.ok).toBe(true); + }); + it("reports cancellation when the signal is already aborted", async () => { const fake = createContractFake(); const client = new PlacementClient({ baseUrl: "http://localhost:8000", fetchImpl: fake.fetch }); diff --git a/web/src/lib/contract/client.ts b/web/src/lib/contract/client.ts index 714dd3873..bf4b3fd40 100644 --- a/web/src/lib/contract/client.ts +++ b/web/src/lib/contract/client.ts @@ -114,16 +114,27 @@ export class PlacementClient { constructor(options: PlacementClientOptions) { this.baseUrl = options.baseUrl.replace(/\/+$/, ""); - this.fetchImpl = options.fetchImpl ?? fetch; + // Native fetch is receiver-sensitive: in a browser, calling it with any object + // other than the global as `this` throws "Illegal invocation", and a member + // call like `this.fetchImpl(...)` would do exactly that. Strip the receiver + // from whatever function was provided; bind the default to the global. + const provided = options.fetchImpl; + this.fetchImpl = provided + ? (...args: Parameters) => provided(...args) + : fetch.bind(globalThis); this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES; this.authToken = options.authToken; } - private async buildReceipt( + /** + * The receipt for a planned submission, built synchronously from an intake so the + * session layer can record the attempt before its first await. + */ + requestReceiptFor( attemptId: string, intake: SceneIntake, - startedAt: number, - ): Promise { + startedAt: number = Date.now(), + ): RequestReceipt { return { attemptId, url: `${this.baseUrl}/v1/placements`, @@ -147,7 +158,7 @@ export class PlacementClient { signal: AbortSignal, ): Promise { const startedAt = Date.now(); - const request = await this.buildReceipt(attemptId, intake, startedAt); + const request = this.requestReceiptFor(attemptId, intake, startedAt); const headers: Record = { "Content-Type": "application/json" }; if (this.authToken !== undefined) { headers.Authorization = `Bearer ${this.authToken}`; diff --git a/web/src/lib/session/store.test.ts b/web/src/lib/session/store.test.ts new file mode 100644 index 000000000..cfcefc348 --- /dev/null +++ b/web/src/lib/session/store.test.ts @@ -0,0 +1,321 @@ +// Store tests: the session store bound to the contract fake, which is the only server +// surface these tests touch — nothing here opens a real network connection. Each test +// drives the operations the page does (select a file, submit, cancel, re-select) and +// asserts on the store's displayed view and the session's recorded receipts, +// witnesses and history. Response hashes are recomputed with node:crypto, an +// implementation independent of the client's crypto.subtle path, so receipt evidence +// is checked, not trusted. + +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + type ContractFake, + createContractFake, + MISMATCHED_SHA256, + sceneText, +} from "../contract/contract-fake.ts"; +import { sha256HexOfText } from "../contract/hash.ts"; +import { DEFAULT_BASE_URL, type SceneFileInput, SessionStore, statusLine } from "./store.ts"; + +function fileNamed(name: string, text: string = sceneText()): SceneFileInput { + return { name, text: async () => text }; +} + +/** A fetch that delegates to a switchable fake, so one scenario can change behaviour mid-flight. */ +function switchableTransport(initial: ContractFake): { + fetch: typeof fetch; + readonly fake: ContractFake; + switchTo(next: ContractFake): void; +} { + let current = initial; + return { + fetch: (input, init) => current.fetch(input, init), + get fake() { + return current; + }, + switchTo(next) { + current = next; + }, + }; +} + +describe("select then submit", () => { + it("binds the newest attempt's result to the display with both receipts attached", async () => { + const fake = createContractFake(); + const store = new SessionStore({ fetchImpl: fake.fetch }); + expect(store.getSnapshot().baseUrl).toBe(DEFAULT_BASE_URL); + expect(store.getSnapshot().display.kind).toBe("no_scene"); + + await store.selectSceneFile(fileNamed("scene.json")); + const text = sceneText(); + const ready = store.getSnapshot(); + expect(ready.scene?.fileName).toBe("scene.json"); + expect(ready.scene?.sha256).toBe(await sha256HexOfText(text)); + expect(ready.scene?.byteLength).toBe(new TextEncoder().encode(text).byteLength); + expect(ready.display.kind).toBe("scene_ready"); + expect(ready.session?.attempts).toHaveLength(0); + + await store.submit(); + const snap = store.getSnapshot(); + expect(snap.display.kind).toBe("bound"); + if (snap.display.kind !== "bound") return; + const session = snap.session; + if (session === null) throw new Error("no session after a bound submission"); + expect(session.currentResult).toBe(snap.display.result); + expect(snap.display.result.decision).toBe("pass"); + expect(snap.display.result.stats.input_sha256).toBe(snap.scene?.sha256); + expect(statusLine(snap.display)).toContain("pass"); + + const attempt = session.attempts.at(-1); + if (attempt === undefined) throw new Error("no attempt recorded"); + expect(attempt.status).toBe("bound"); + // The request receipt: what was sent, and its hash. + expect(attempt.request.url).toBe("http://localhost:8000/v1/placements"); + expect(attempt.request.method).toBe("POST"); + expect(attempt.request.contentType).toBe("application/json"); + expect(attempt.request.byteLength).toBe(snap.scene?.byteLength); + expect(attempt.request.requestSha256).toBe(snap.scene?.sha256); + // The response receipt: what came back, hashed independently of the client. + expect(attempt.outcome?.type).toBe("result"); + if (attempt.outcome?.type !== "result") return; + expect(attempt.outcome.response.status).toBe(200); + expect(attempt.outcome.response.contentType).toBe("application/json"); + expect(attempt.outcome.response.bodySha256).toBe( + createHash("sha256") + .update(fake.lastResponseText ?? "") + .digest("hex"), + ); + // The fake saw the scene's exact bytes and nothing authorizing. + expect(fake.requests).toHaveLength(1); + expect(new TextDecoder().decode(fake.requests[0]?.body ?? new Uint8Array())).toBe(text); + expect(fake.requests[0]?.headers.authorization).toBeUndefined(); + // A clean bound answer leaves no witnesses. + expect(session.witnesses).toHaveLength(0); + }); + + it("sends the bearer token to the wire once and never exposes it in any snapshot or receipt", async () => { + const fake = createContractFake(); + const store = new SessionStore({ fetchImpl: fake.fetch }); + store.setBaseUrl("http://localhost:9000"); + store.setToken("sekrit-token-abc"); + expect(store.getSnapshot().hasToken).toBe(true); + + await store.selectSceneFile(fileNamed("scene.json")); + await store.submit(); + const snap = store.getSnapshot(); + expect(snap.display.kind).toBe("bound"); + expect(fake.requests[0]?.url).toBe("http://localhost:9000/v1/placements"); + expect(fake.requests[0]?.headers.authorization).toBe("Bearer sekrit-token-abc"); + + // The whole snapshot — session, attempts, receipts, witnesses, display — carries + // no trace of the token. + expect(JSON.stringify(snap)).not.toContain("sekrit-token-abc"); + + store.setToken(""); + expect(store.getSnapshot().hasToken).toBe(false); + }); +}); + +describe("cancel mid-flight", () => { + it("records a cancelled outcome with its request receipt, and a new attempt can still bind", async () => { + const slow = createContractFake({ + behavior: { kind: "delayed_then_abort", delayMs: 50 }, + }); + const fast = createContractFake(); + const transport = switchableTransport(slow); + const store = new SessionStore({ fetchImpl: transport.fetch }); + await store.selectSceneFile(fileNamed("scene.json")); + + const first = store.submit(); + expect(store.getSnapshot().session?.attempts.at(-1)?.status).toBe("submitting"); + store.cancel(); + await first; + + const cancelled = store.getSnapshot(); + expect(cancelled.display.kind).toBe("cancelled"); + const attempt = cancelled.session?.attempts.at(-1); + expect(attempt?.status).toBe("cancelled"); + expect(attempt?.outcome?.type).toBe("cancelled"); + // The aborted attempt keeps the receipt of what it sent. + expect(attempt?.request.requestSha256).toBe(cancelled.scene?.sha256); + expect(cancelled.session?.currentResult).toBeNull(); + + // Cancelling a settled attempt changes nothing. + store.cancel(); + expect(store.getSnapshot().session?.attempts).toHaveLength(1); + + transport.switchTo(fast); + await store.submit(); + const retried = store.getSnapshot(); + expect(retried.display.kind).toBe("bound"); + expect(retried.session?.attempts).toHaveLength(2); + expect(retried.session?.attempts[0]?.status).toBe("cancelled"); + expect(retried.session?.currentResult?.stats.input_sha256).toBe(retried.scene?.sha256); + }); +}); + +describe("refusal", () => { + it("shows the server's message and path from the 422 envelope and witnesses the refusal", async () => { + const fake = createContractFake({ + behavior: { + kind: "refused", + status: 422, + code: "invalid_scene", + message: "Wall 'north' has no usable baseline.", + path: "/walls/0/baseline", + }, + }); + const store = new SessionStore({ fetchImpl: fake.fetch }); + await store.selectSceneFile(fileNamed("scene.json")); + await store.submit(); + + const snap = store.getSnapshot(); + expect(snap.display.kind).toBe("refused"); + if (snap.display.kind !== "refused") return; + expect(snap.display.failure.kind).toBe("server_error"); + if (snap.display.failure.kind !== "server_error") return; + expect(snap.display.failure.envelope.code).toBe("invalid_scene"); + expect(snap.display.failure.envelope.message).toBe("Wall 'north' has no usable baseline."); + expect(snap.display.failure.envelope.path).toBe("/walls/0/baseline"); + expect(snap.session?.currentResult).toBeNull(); + + const attempt = snap.session?.attempts.at(-1); + expect(attempt?.status).toBe("refused"); + expect(attempt?.outcome?.type).toBe("refused"); + if (attempt?.outcome?.type !== "refused") return; + expect(attempt.outcome.response.status).toBe(422); + expect(attempt.outcome.response.bodySha256).toBe( + createHash("sha256") + .update(fake.lastResponseText ?? "") + .digest("hex"), + ); + + const witness = snap.session?.witnesses.at(-1); + expect(witness?.kind).toBe("refused"); + if (witness?.kind !== "refused") return; + expect(witness.code).toBe("invalid_scene"); + expect(witness.detail).toBe("Wall 'north' has no usable baseline."); + }); +}); + +describe("mismatched answer", () => { + it("keeps the result in history but never displays it, and witnesses the mismatch", async () => { + const fake = createContractFake({ behavior: { kind: "mismatched_sha" } }); + const store = new SessionStore({ fetchImpl: fake.fetch }); + await store.selectSceneFile(fileNamed("scene.json")); + await store.submit(); + + const snap = store.getSnapshot(); + expect(snap.display.kind).toBe("mismatched"); + expect(snap.session?.currentResult).toBeNull(); + + const attempt = snap.session?.attempts.at(-1); + expect(attempt?.status).toBe("mismatched"); + expect(attempt?.outcome?.type).toBe("result"); + if (attempt?.outcome?.type !== "result") return; + // The answer is retained on the attempt that produced it, labelled with its binding. + expect(attempt.outcome.result.summary.length).toBeGreaterThan(0); + expect(attempt.outcome.binding.kind).toBe("mismatched"); + if (attempt.outcome.binding.kind !== "mismatched") return; + expect(attempt.outcome.binding.requestSha256).toBe(snap.scene?.sha256); + expect(attempt.outcome.binding.resultSha256).toBe(MISMATCHED_SHA256); + + const witness = snap.session?.witnesses.at(-1); + expect(witness?.kind).toBe("mismatched_result"); + if (witness?.kind !== "mismatched_result") return; + expect(witness.attemptId).toBe(attempt.attemptId); + expect(witness.resultSha256).toBe(MISMATCHED_SHA256); + + // A retry against an honest answer binds and displays. + const honest = createContractFake(); + const transport = switchableTransport(fake); + const store2 = new SessionStore({ fetchImpl: transport.fetch }); + await store2.selectSceneFile(fileNamed("scene.json")); + transport.switchTo(honest); + await store2.submit(); + expect(store2.getSnapshot().display.kind).toBe("bound"); + }); +}); + +describe("a late outcome for a superseded attempt", () => { + it("is witnessed and recorded in history, never displayed over the newer answer", async () => { + const slow = createContractFake({ + behavior: { kind: "delayed_then_abort", delayMs: 120 }, + }); + const fast = createContractFake(); + const transport = switchableTransport(slow); + const store = new SessionStore({ fetchImpl: transport.fetch }); + await store.selectSceneFile(fileNamed("scene.json")); + + const first = store.submit(); // lands late, with a bound answer for its own bytes + transport.switchTo(fast); + await store.submit(); // binds now + const mid = store.getSnapshot(); + expect(mid.display.kind).toBe("bound"); + + await first; + const snap = store.getSnapshot(); + expect(snap.display.kind).toBe("bound"); + const attempts = snap.session?.attempts ?? []; + expect(attempts).toHaveLength(2); + expect(attempts[0]?.status).toBe("stale"); + expect(attempts[0]?.outcome?.type).toBe("result"); + // The display and the current result still belong to the newest attempt. + expect(snap.display.kind === "bound" ? snap.display.attemptId : null).toBe( + attempts[1]?.attemptId, + ); + expect(snap.session?.currentResult?.stats.input_sha256).toBe(snap.scene?.sha256); + + const witness = snap.session?.witnesses.find((w) => w.kind === "stale_outcome"); + if (witness?.kind !== "stale_outcome") throw new Error("no stale_outcome witness recorded"); + expect(witness.attemptId).toBe(attempts[0]?.attemptId); + expect(witness.newestAttemptId).toBe(attempts[1]?.attemptId); + }); +}); + +describe("a refused scene selection", () => { + it("is refused locally, records no session, and never reaches the wire", async () => { + const fake = createContractFake(); + const store = new SessionStore({ fetchImpl: fake.fetch }); + + await store.selectSceneFile(fileNamed("broken.json", "{ not json")); + let snap = store.getSnapshot(); + expect(snap.intakeError?.code).toBe("invalid_json"); + expect(snap.session).toBeNull(); + expect(snap.display.kind).toBe("no_scene"); + await store.submit(); // a no-op without a session + expect(fake.requests).toHaveLength(0); + + await store.selectSceneFile(fileNamed("shape.json", JSON.stringify({ nope: true }))); + snap = store.getSnapshot(); + expect(snap.intakeError?.code).toBe("invalid_scene"); + expect(snap.intakeError?.path).toBe("/meter"); + expect(fake.requests).toHaveLength(0); + }); +}); + +describe("re-selecting a scene mid-flight", () => { + it("abandons the in-flight attempt and opens a fresh session it cannot touch", async () => { + const slow = createContractFake({ + behavior: { kind: "delayed_then_abort", delayMs: 120 }, + }); + const store = new SessionStore({ fetchImpl: slow.fetch }); + await store.selectSceneFile(fileNamed("first.json")); + const abandoned = store.submit(); + const oldSessionId = store.getSnapshot().session?.sessionId; + + await store.selectSceneFile(fileNamed("second.json")); + const fresh = store.getSnapshot(); + expect(fresh.scene?.fileName).toBe("second.json"); + expect(fresh.session?.sessionId).not.toBe(oldSessionId); + expect(fresh.session?.attempts).toHaveLength(0); + expect(fresh.display.kind).toBe("scene_ready"); + + await abandoned; // the abandoned attempt settles; it must not touch the new session + const settled = store.getSnapshot(); + expect(settled.session?.sessionId).toBe(fresh.session?.sessionId); + expect(settled.session?.attempts).toHaveLength(0); + expect(settled.display.kind).toBe("scene_ready"); + expect(settled.session?.witnesses).toHaveLength(0); + }); +}); diff --git a/web/src/lib/session/store.ts b/web/src/lib/session/store.ts new file mode 100644 index 000000000..82811f0a1 --- /dev/null +++ b/web/src/lib/session/store.ts @@ -0,0 +1,355 @@ +// The session store: framework-plain state (no React import) that binds the contract +// layer to the page. It owns the one SessionRecord the UI presents and drives the +// operations the page exposes: +// +// - selectSceneFile: read the file's exact text, run scene intake (typed view, the +// exact upload bytes, their SHA-256, the version report), and open a fresh session +// for it. A selection while a request is in flight aborts that request; the +// abandoned attempt's late completion finds a different session id and is dropped, +// never applied across sessions. +// - submit: startAttempt (which demotes any older submitting attempt), then POST the +// exact bytes through PlacementClient under an AbortSignal, then applyOutcome +// whatever came back: a bound result, a refusal, a transport failure, or a +// cancellation. +// - cancel: abort the in-flight attempt. The settled report records a cancelled +// outcome on the attempt, alongside the receipt of what was sent. +// +// What the panel shows is derived in one place, displayedView: only the newest +// attempt's outcome is ever presented, and its result only when bound. Stale +// arrivals, mismatched answers and unparseable bodies are recorded as witnesses on +// the attempts that produced them — never displayed as the current answer. The +// bearer token lives in memory for the next submission only: never persisted, never +// logged, absent from every snapshot and receipt. + +import { PlacementClient, type SubmitReport } from "../contract/client.ts"; +import type { PlacementFailure } from "../contract/errors.ts"; +import { + intakeSceneText, + type SceneIntake, + SceneIntakeError, + type SceneIntakeIssue, +} from "../contract/scene.ts"; +import { + type AttemptOutcome, + applyOutcome, + createSession, + newAttemptId, + type SessionRecord, + startAttempt, +} from "../contract/session.ts"; +import type { PlacementResult } from "../contract/types.ts"; +import type { SceneVersionReport } from "../contract/versions.ts"; + +export const DEFAULT_BASE_URL = "http://localhost:8000"; + +/** Anything a file picker hands over: a DOM File, or a test stub with the same shape. */ +export interface SceneFileInput { + readonly name: string; + text(): Promise; +} + +/** What the page shows about the selected scene, once intake has succeeded. */ +export interface SceneSummary { + readonly fileName: string; + readonly sha256: string; + readonly byteLength: number; + readonly version: SceneVersionReport; + readonly warnings: readonly string[]; +} + +/** + * The one view the result panel is allowed to render. It is derived only from the + * newest attempt, so an older attempt's outcome cannot reach the panel through it. + */ +export type DisplayView = + | { readonly kind: "no_scene" } + | { readonly kind: "scene_ready" } + | { readonly kind: "submitting"; readonly attemptId: string; readonly startedAt: number } + | { + readonly kind: "bound"; + readonly attemptId: string; + readonly result: PlacementResult; + readonly additiveNotes: readonly string[]; + } + | { + readonly kind: "mismatched"; + readonly attemptId: string; + readonly requestSha256: string; + readonly resultSha256: string; + } + | { readonly kind: "refused"; readonly attemptId: string; readonly failure: PlacementFailure } + | { readonly kind: "failed"; readonly attemptId: string; readonly failure: PlacementFailure } + | { readonly kind: "unparseable_result"; readonly attemptId: string; readonly detail: string } + | { readonly kind: "cancelled"; readonly attemptId: string } + | { readonly kind: "superseded"; readonly attemptId: string }; + +export interface SessionStoreState { + readonly baseUrl: string; + /** True when a token is held in memory; the token itself is never in the snapshot. */ + readonly hasToken: boolean; + readonly scene: SceneSummary | null; + /** Why the last selection was refused locally (unreadable, bad JSON, wrong shape). */ + readonly intakeError: SceneIntakeIssue | null; + readonly session: SessionRecord | null; + readonly display: DisplayView; +} + +export interface SessionStoreOptions { + /** Injectable transport (tests use the contract fake). Defaults to globalThis.fetch. */ + readonly fetchImpl?: typeof fetch; + readonly baseUrl?: string; +} + +/** Maps a client report onto the outcome the session layer applies. */ +function outcomeFromReport(report: SubmitReport): AttemptOutcome { + if (report.ok) { + return report.outcome; + } + const { failure } = report; + if (failure.kind === "aborted") { + return { type: "cancelled" }; + } + if (failure.kind === "network_error") { + return { type: "failed", failure }; + } + // server_error / unparseable_error: the server answered, so a response receipt exists. + const response = report.exchange.response; + return response === null ? { type: "failed", failure } : { type: "refused", response, failure }; +} + +function shortAttemptId(attemptId: string): string { + const marker = attemptId.indexOf(":"); + return marker === -1 ? attemptId : attemptId.slice(marker + 1); +} + +/** The newest attempt's view, or the quiet state before any attempt. */ +export function displayedView(session: SessionRecord | null): DisplayView { + if (session === null) { + return { kind: "no_scene" }; + } + const newest = session.attempts.at(-1); + if (newest === undefined) { + return { kind: "scene_ready" }; + } + const attemptId = newest.attemptId; + const outcome = newest.outcome; + switch (newest.status) { + case "submitting": + return { kind: "submitting", attemptId, startedAt: newest.startedAt }; + case "bound": + if (outcome?.type === "result") { + return { + kind: "bound", + attemptId, + result: outcome.result, + additiveNotes: outcome.additiveNotes, + }; + } + break; + case "mismatched": + if (outcome?.type === "result" && outcome.binding.kind === "mismatched") { + return { + kind: "mismatched", + attemptId, + requestSha256: outcome.binding.requestSha256, + resultSha256: outcome.binding.resultSha256, + }; + } + break; + case "refused": + if (outcome?.type === "refused") { + return { kind: "refused", attemptId, failure: outcome.failure }; + } + break; + case "failed": + if (outcome?.type === "unparseable_result") { + return { kind: "unparseable_result", attemptId, detail: outcome.failure.detail }; + } + if (outcome?.type === "failed") { + return { kind: "failed", attemptId, failure: outcome.failure }; + } + break; + case "cancelled": + return { kind: "cancelled", attemptId }; + case "stale": + break; + } + // The status/outcome pairs above are exhaustive while the contract layer's + // invariants hold; reaching here means the pair was inconsistent, so the panel + // shows a neutral "superseded" notice rather than guessing at an answer. + return { kind: "superseded", attemptId }; +} + +/** The one-line attempt status the page leads with. */ +export function statusLine(display: DisplayView): string { + switch (display.kind) { + case "no_scene": + return "No scene selected."; + case "scene_ready": + return "Scene ready. No attempts yet."; + case "submitting": + return `${shortAttemptId(display.attemptId)}: submitting...`; + case "bound": + return `${shortAttemptId(display.attemptId)}: bound, decision ${display.result.decision}.`; + case "mismatched": + return `${shortAttemptId(display.attemptId)}: the answer names different bytes; recorded as a witness, not displayed.`; + case "refused": + return `${shortAttemptId(display.attemptId)}: refused by the server.`; + case "failed": + return `${shortAttemptId(display.attemptId)}: failed.`; + case "unparseable_result": + return `${shortAttemptId(display.attemptId)}: the server's answer is not a readable result.`; + case "cancelled": + return `${shortAttemptId(display.attemptId)}: cancelled.`; + case "superseded": + return `${shortAttemptId(display.attemptId)}: superseded.`; + } +} + +export class SessionStore { + private readonly fetchImpl: typeof fetch; + private readonly listeners = new Set<() => void>(); + private baseUrl: string; + /** In memory only: never persisted, never logged, never in a snapshot or receipt. */ + private token = ""; + private intake: SceneIntake | null = null; + private session: SessionRecord | null = null; + private sceneSummary: SceneSummary | null = null; + private intakeError: SceneIntakeIssue | null = null; + private inFlight: AbortController | null = null; + private current: SessionStoreState; + + constructor(options: SessionStoreOptions = {}) { + this.fetchImpl = options.fetchImpl ?? fetch; + this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + this.current = { + baseUrl: this.baseUrl, + hasToken: false, + scene: null, + intakeError: null, + session: null, + display: { kind: "no_scene" }, + }; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + getSnapshot(): SessionStoreState { + return this.current; + } + + setBaseUrl(url: string): void { + this.baseUrl = url; + this.publish(); + } + + /** Held in memory for the next submission only; pass "" to clear. */ + setToken(token: string): void { + this.token = token; + this.publish(); + } + + async selectSceneFile(file: SceneFileInput): Promise { + // A new selection supersedes anything in flight: abort the old request, then open + // a fresh session for the new file. The abandoned attempt's completion finds a + // different session id and is dropped, not applied across sessions. + this.inFlight?.abort(); + this.inFlight = null; + let text: string; + try { + text = await file.text(); + } catch (error) { + this.intakeError = { + code: "unreadable_file", + message: `The file could not be read: ${(error as Error).message}`, + path: null, + }; + this.publish(); + return; + } + try { + const intake = await intakeSceneText(text); + this.intake = intake; + this.session = createSession(`session-${crypto.randomUUID()}`, file.name, intake.sha256); + this.sceneSummary = { + fileName: file.name, + sha256: intake.sha256, + byteLength: intake.bytes.byteLength, + version: intake.version, + warnings: intake.warnings, + }; + this.intakeError = null; + this.publish(); + } catch (error) { + // A refused selection changes nothing about a session already in progress. + this.intakeError = + error instanceof SceneIntakeError + ? { code: error.code, message: error.message, path: error.path } + : { + code: "unreadable_file", + message: (error as Error).message, + path: null, + }; + this.publish(); + } + } + + async submit(): Promise { + const intake = this.intake; + const session = this.session; + if (intake === null || session === null) { + return; // Nothing to submit: the page disables the control alongside this guard. + } + const client = new PlacementClient({ + baseUrl: this.baseUrl, + fetchImpl: this.fetchImpl, + ...(this.token === "" ? {} : { authToken: this.token }), + }); + const sessionId = session.sessionId; + const attemptId = newAttemptId(sessionId); + // Synchronous prologue: the attempt is recorded and the page shows "submitting" + // before the first await, so Cancel is live from the moment submission begins. + this.session = startAttempt(session, attemptId, client.requestReceiptFor(attemptId, intake)); + const controller = new AbortController(); + this.inFlight = controller; + this.publish(); + + const report = await client.submitPlacement(intake, attemptId, controller.signal); + if (this.inFlight === controller) { + this.inFlight = null; + } + if (this.session === null || this.session.sessionId !== sessionId) { + return; // The scene was re-selected mid-flight; that outcome belongs to a discarded session. + } + this.session = applyOutcome(this.session, attemptId, outcomeFromReport(report)); + this.publish(); + } + + cancel(): void { + // Only an in-flight attempt may be cancelled; a settled attempt's record stands. + const newest = this.session?.attempts.at(-1); + if (newest === undefined || newest.status !== "submitting") { + return; + } + this.inFlight?.abort(); + } + + private publish(): void { + this.current = { + baseUrl: this.baseUrl, + hasToken: this.token !== "", + scene: this.sceneSummary, + intakeError: this.intakeError, + session: this.session, + display: displayedView(this.session), + }; + for (const listener of this.listeners) { + listener(); + } + } +} From 8e8b775089e7df007550178e2c1db0c5d712ef0a Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:44:45 +0000 Subject: [PATCH 3/4] feat(web): add placement inspector, session reopen, and redacted export/import M3 of the BaseScanning web-client work, on top of M2's session store and receipts. - Inspector.tsx renders the newest bound result from the placement response (decision quoted as the result's own claim, reasons with codes, checks with measurement vs threshold and rule citation, spot, route, ends, missing evidence, policy, sweep, statistics) plus the attempt history and witness list, labelled as history and never promoted to the current answer. - persist.ts adds a versioned browser-local session record with defensive parsing: scene text is re-intaken on restore, the newest bound result is recomputed from attempts, an interrupted "submitting" attempt restores as cancelled with the cancelled outcome, and a doctored status label cannot promote a mismatched result to the display. - SessionStore persists the record after every published change, exposes the saved-session summary and restore/storage errors, and adds reopen, discard, export, and import; the bearer token stays in memory only. - SessionPanel.tsx and App wiring offer reopen, discard, and redacted export/import; web/README.md documents exactly what an export contains and excludes. Tests (15 new, 122 total): the inspector renders a synthetic bound result's checks, spot, route, reasons, ends, missing evidence and policy from the contract fake; witness history shows refused, mismatched, unparseable and stale entries without promoting them; persist->reopen restores attempts, receipts, scene bytes and the displayed result with no token in storage; export->import roundtrips with credentials absent. No test touches a real network. Co-authored-by: obvious-autobuild[bot] <262744130+obvious-autobuild[bot]@users.noreply.github.com> --- web/README.md | 40 ++ web/src/App.tsx | 39 +- web/src/components/Inspector.tsx | 421 ++++++++++++++++++++ web/src/components/SessionPanel.tsx | 90 +++++ web/src/components/inspector.test.tsx | 223 +++++++++++ web/src/lib/session/persist.test.ts | 321 +++++++++++++++ web/src/lib/session/persist.ts | 550 ++++++++++++++++++++++++++ web/src/lib/session/store.ts | 203 ++++++++++ 8 files changed, 1884 insertions(+), 3 deletions(-) create mode 100644 web/src/components/Inspector.tsx create mode 100644 web/src/components/SessionPanel.tsx create mode 100644 web/src/components/inspector.test.tsx create mode 100644 web/src/lib/session/persist.test.ts create mode 100644 web/src/lib/session/persist.ts diff --git a/web/README.md b/web/README.md index a5cf7edb0..f9a78cc06 100644 --- a/web/README.md +++ b/web/README.md @@ -30,6 +30,46 @@ pnpm install CI (`.github/workflows/web.yml`, job "Web checks") runs `pnpm install --frozen-lockfile` and then `pnpm run check`. +## Saved sessions: reopen, export, import + +The app keeps one session record in browser-local storage (`localStorage`) and +publishes a snapshot there after every change. Closing the tab does not end the +session: the next page load offers to reopen it. Reopening re-intakes the saved +scene text through the same intake a fresh upload takes, recomputes the newest +bound result from the attempt history, and starts with **no bearer token** — the +token is held in memory for the app's lifetime only and is never written to +storage. An attempt recorded mid-flight ("submitting") can never settle, so +reopening marks it `cancelled` with a cancelled outcome; a new attempt can start +immediately. Export (button "Export session (redacted JSON)") and import write and read the same +redacted shape in a standalone file. + +**What an export (and the saved record) contains** + +- `format` and `version` (`basescanning-session-record` v1) and a `savedAt` time. +- `scene`: the file name and the file's **exact text** — re-intaking it reproduces + the original upload bytes and their SHA-256. +- `session`: the attempt history — per attempt the status, the request receipt + (URL, method, content type, byte length, `requestSha256`, started time), the + response receipt where one exists (status, content type, body byte length, + `bodySha256`, a capped `bodyExcerpt`, truncation flag, timing), the outcome + (including any bound result), and the witnesses (refusals, mismatched answers, + unreadable bodies, late arrivals) with their reasons. + +**What it excludes** + +- The **bearer token** — never stored, never exported, never logged; it lives in + memory between selection and the next submission only. +- HTTP **headers**, including any `Authorization` header — receipts record the + URL, sizes, hashes and timings, not the wire headers. +- The response **body beyond the capped excerpt** — long bodies are truncated by + the client, and the excerpt is bounded in the record. +- Anything outside this app's session: no other tab's or app's storage, no + browser state beyond the single record under one key. + +Because the scene text travels in full, treat an export file as you treat the +original `scene.json`: it is a capture record, and the repository's rule about +synthetic data applies to it too. + ## Conventions - Lengths are stored in meters. Convert to feet and inches only for display, using `src/lib/units.ts`. diff --git a/web/src/App.tsx b/web/src/App.tsx index 503ace666..3b45eedf2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,13 +1,18 @@ // The placement session page. All state lives in the session store (one SessionRecord // at a time); React subscribes through useSyncExternalStore and renders the store's -// snapshot. The page owns exactly the M2 controls: server fields, the scene picker, -// the attempt status line, the run/cancel buttons, and the outcome panel that can -// only ever show the newest attempt's bound outcome or refusal. +// snapshot. The page owns the M2 controls (server fields, the scene picker, the attempt +// status line, run/cancel, the outcome panel that can only ever show the newest +// attempt's bound outcome or refusal) and the M3 controls: the placement inspector +// (the newest bound result in full, plus attempt history and witnesses kept clearly +// separate from the current answer) and the session record controls (browser-local +// persistence with reopen, and redacted export/import). import { useCallback, useRef, useSyncExternalStore } from "react"; +import { Inspector } from "./components/Inspector.tsx"; import { OutcomePanel } from "./components/OutcomePanel.tsx"; import { ScenePicker } from "./components/ScenePicker.tsx"; import { ServerFields } from "./components/ServerFields.tsx"; +import { SessionPanel } from "./components/SessionPanel.tsx"; import { SessionStore, statusLine } from "./lib/session/store.ts"; export function App() { @@ -23,6 +28,20 @@ export function App() { const submitting = state.display.kind === "submitting"; const canSubmit = state.scene !== null && !submitting; + const handleExport = useCallback(() => { + const json = store.exportSession(); + if (json === null) { + return; + } + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `basescanning-session-${new Date().toISOString().replace(/[:.]/g, "-")}.json`; + anchor.click(); + URL.revokeObjectURL(url); + }, [store]); + return (

House Scan: web capture

@@ -40,6 +59,19 @@ export function App() { intakeError={state.intakeError} onSelect={(file) => void store.selectSceneFile(file)} /> + void store.reopenSession()} + onDiscard={() => store.discardSavedSession()} + onExport={handleExport} + onImport={(file) => { + void file.text().then((text) => store.importSession(text)); + }} + />

{statusLine(state.display)}

+
); diff --git a/web/src/components/Inspector.tsx b/web/src/components/Inspector.tsx new file mode 100644 index 000000000..404952112 --- /dev/null +++ b/web/src/components/Inspector.tsx @@ -0,0 +1,421 @@ +// The placement inspector: everything the newest bound result actually says, plus the +// full attempt history and the session's witnesses, in one reviewable view. +// +// Two rules shape the copy here: +// +// - The inspector quotes the placement response; it never adds an opinion of its own. +// The decision word is presented as the result's own claim ("the result's own +// decision: pass"), and nothing in this component calls a placement safe, approved +// or verified. Where the response is silent (a null spot or route), it says so +// instead of filling the gap. +// - The attempt history is history. Entries for stale, mismatched, unreadable and +// cancelled attempts carry their receipts and outcomes, each labelled as not the +// current answer; a result that stayed in history (a stale or mismatched one) is +// never rendered as a result here — only the fact that one exists and why it is not +// displayed. Witnesses get their own block for the same reason. + +import { failureHeadline } from "../lib/contract/errors.ts"; +import type { + SessionRecord, + SessionWitness, + SubmissionAttempt, + SubmissionStatus, +} from "../lib/contract/session.ts"; +import type { + MissingEvidence, + PlacementResult, + ResultCheck, + ResultRoute, + ResultSpot, +} from "../lib/contract/types.ts"; +import type { DisplayView } from "../lib/session/store.ts"; + +function ft(n: number): string { + return `${n} ft`; +} + +function SpanText({ span }: { readonly span: readonly [number, number] }) { + return ( + <> + {ft(span[0])} to {ft(span[1])} + + ); +} + +function PointText({ point }: { readonly point: readonly [number, number] }) { + return ( + <> + [{point[0]}, {point[1]}] + + ); +} + +function shortAttemptId(attemptId: string): string { + const marker = attemptId.indexOf(":"); + return marker === -1 ? attemptId : attemptId.slice(marker + 1); +} + +const STATUS_PHRASES: Readonly> = { + submitting: "in flight", + bound: "bound — the answer matches the uploaded bytes", + mismatched: "mismatched — the answer named different bytes; recorded, never displayed", + refused: "refused by the server", + failed: "failed", + cancelled: "cancelled before an answer arrived", + stale: "superseded by a newer attempt", +}; + +function witnessLabel(kind: SessionWitness["kind"]): string { + switch (kind) { + case "mismatched_result": + return "mismatched result"; + case "stale_outcome": + return "late arrival for a superseded attempt"; + case "unparseable_result": + return "unreadable 200 body"; + case "refused": + return "refusal"; + } +} + +function witnessDetail(witness: SessionWitness): string { + switch (witness.kind) { + case "mismatched_result": + return `the request sent ${witness.requestSha256}; the answer claims ${witness.resultSha256}.`; + case "stale_outcome": + return `the outcome belongs to ${shortAttemptId(witness.attemptId)}, but ${shortAttemptId(witness.newestAttemptId)} had become the newest attempt.`; + case "unparseable_result": + return witness.reason; + case "refused": + return `${witness.code}: ${witness.detail}`; + } +} + +function outcomeLine(attempt: SubmissionAttempt, isCurrent: boolean): string { + const outcome = attempt.outcome; + if (outcome === null) { + return "No outcome recorded yet."; + } + switch (outcome.type) { + case "result": + return outcome.binding.kind === "bound" + ? "Carries a result bound to the exact uploaded bytes." + + (isCurrent ? "" : " Its result stays in history and is not displayed as the answer.") + : `Carries a result naming different bytes (${outcome.binding.resultSha256}) — kept as a witness, never displayed.`; + case "unparseable_result": + return `The 200 body could not be read as a result (${outcome.failure.reason}); kept as a witness, never displayed.`; + case "refused": + return `Refused with HTTP ${outcome.response.status} (${outcome.failure.kind === "server_error" ? outcome.failure.envelope.code : "non-envelope body"}).`; + case "failed": + return failureHeadline(outcome.failure); + case "cancelled": + return "No answer arrived; the receipt of what was sent is kept."; + } +} + +function CheckRow({ check }: { readonly check: ResultCheck }) { + const measured = + check.measured_ft === null + ? null + : check.comparison === null || check.threshold_ft === null + ? ` Measured ${ft(check.measured_ft)}${check.plus_minus_ft === null ? "" : ` ± ${ft(check.plus_minus_ft)}`}.` + : ` Measured ${ft(check.measured_ft)}${check.plus_minus_ft === null ? "" : ` ± ${ft(check.plus_minus_ft)}`} against a ${ + check.comparison === "at_least" ? "minimum" : "maximum" + } of ${ft(check.threshold_ft)}.`; + return ( +
  • + {check.label} ({check.id}): {check.outcome}.{" "} + {check.reason} + {measured} + {check.review_threshold_ft === undefined ? null : ( + <> A review threshold applies at {ft(check.review_threshold_ft)}. + )} + {check.subject === null ? null : ( + <> + {" "} + Subject: {check.subject}. + + )}{" "} + Rule {check.rule.key} — source: {check.rule.source} + {check.rule.placeholder ? " (placeholder value with no public source)" : ""}. +
  • + ); +} + +function SpotSummary({ spot }: { readonly spot: ResultSpot }) { + return ( +

    + Wall {spot.wall_id} segment {spot.segment}, span + , width {ft(spot.width_ft)}, depth {ft(spot.depth_ft)}, height {ft(spot.height_ft)}, center{" "} + , outcome {spot.outcome} + {spot.route_length_ft === null ? null : <>, route {ft(spot.route_length_ft)}}. +

    + ); +} + +function RouteSummary({ route }: { readonly route: ResultRoute }) { + return ( +
    +

    + Length {ft(route.length_ft)} ± {ft(route.plus_minus_ft)}, height {ft(route.height_ft)}, + outcome {route.outcome} + {route.length_is_lower_bound === true + ? " (a lower bound; the true length may be longer)" + : ""} + . +

    + {route.detours === undefined || route.detours.length === 0 ? null : ( +

    + Detours:{" "} + {route.detours.map((detour, index) => ( + + {index === 0 ? "" : "; "} + {detour.subject} adds {ft(detour.extra_ft)} + + ))} + . +

    + )} + {route.crossings === undefined || route.crossings.length === 0 ? null : ( +

    + Crossings:{" "} + {route.crossings.map((crossing, index) => ( + + {index === 0 ? "" : "; "} + {crossing.subject} ( + ): {crossing.effect} + + ))} + . +

    + )} +
    + ); +} + +function MissingEvidenceRow({ missing }: { readonly missing: MissingEvidence }) { + return ( +
  • + {missing.kind} + {missing.band === undefined ? null : <> band {missing.band}} + {missing.side === undefined ? null : <>, {missing.side} side} + {missing.span_ft === undefined ? null : ( + <> + , span + + )} + {missing.out_ft === undefined ? null : <>, out to {ft(missing.out_ft)}} + {missing.checks === undefined || missing.checks.length === 0 ? null : ( + <>; checks: {missing.checks.join(", ")} + )} + : {missing.message} +
  • + ); +} + +function ResultDetail({ result }: { readonly result: PlacementResult }) { + return ( +
    +

    + The result's own decision: {result.decision} +

    +

    Summary: {result.summary}

    +

    Reasons

    + {result.reasons.length === 0 ? ( +

    The result gives no reasons.

    + ) : ( +
      + {result.reasons.map((reason) => ( +
    • + {reason.code}: {reason.message} + {reason.checks === undefined || reason.checks.length === 0 ? null : ( + <> (checks: {reason.checks.join(", ")}) + )} +
    • + ))} +
    + )} +

    Spot

    + {result.spot === null ? ( +

    No spot fits the observed evidence.

    + ) : ( + + )} + {result.nearest_considered === undefined || result.nearest_considered === null ? null : ( +

    + Nearest considered spot, not chosen: wall {result.nearest_considered.wall_id}{" "} + segment {result.nearest_considered.segment}, outcome{" "} + {result.nearest_considered.outcome}. +

    + )} +

    Route

    + {result.route === null ? ( +

    No route was produced.

    + ) : ( + + )} +

    Checks

    + {result.checks.length === 0 ? ( +

    No checks ran.

    + ) : ( +
      + {result.checks.map((check) => ( + + ))} +
    + )} +

    Ends

    +

    + Left end: {result.ends.left.kind} at {ft(result.ends.left.s_ft)} (point{" "} + ) + {result.ends.left.beyond_reach === true ? ", beyond reach" : ""}; right end:{" "} + {result.ends.right.kind} at {ft(result.ends.right.s_ft)} (point{" "} + ) + {result.ends.right.beyond_reach === true ? ", beyond reach" : ""}. +

    + {result.missing_evidence.length === 0 ? null : ( + <> +

    Missing evidence

    +
      + {result.missing_evidence.map((missing) => ( + + ))} +
    + + )} +

    Policy

    +

    + {result.policy.id === null ? "No named policy" : {result.policy.id}} + {result.policy.version === null ? null : <> version {result.policy.version}}, + auto-approve {result.policy.auto_approve ? "on" : "off"} + {result.policy.allow_reject === undefined ? null : ( + <> (allow_reject {result.policy.allow_reject ? "on" : "off"}) + )} + , sources {result.policy.sources.join(", ")}, rules{" "} + {result.policy.rules_sha256}. + {result.policy.notice === null ? null : <> {result.policy.notice}} +

    + {result.sweep.length === 0 ? null : ( + <> +

    Sweep

    +
      + {result.sweep.map((run) => ( +
    • + Wall {run.wall_id} + {run.segment === undefined ? null : <> segment {run.segment}}, start{" "} + : {run.outcome} + {run.failing.length === 0 ? null : <>; failing {run.failing.join(", ")}} + {run.unsure.length === 0 ? null : <>; unsure {run.unsure.join(", ")}} +
    • + ))} +
    + + )} +

    Statistics

    +

    + {result.stats.candidates} candidate{result.stats.candidates === 1 ? "" : "s"}:{" "} + {result.stats.pass} pass, {result.stats.unsure} unsure, {result.stats.fail} fail, in{" "} + {result.stats.elapsed_ms} ms. Bound to upload {result.stats.input_sha256}. +

    +
    + ); +} + +function AttemptRow({ + attempt, + isCurrent, +}: { + readonly attempt: SubmissionAttempt; + readonly isCurrent: boolean; +}) { + return ( +
  • + + Attempt {shortAttemptId(attempt.attemptId)} — {STATUS_PHRASES[attempt.status]} + + {isCurrent + ? " (the attempt whose bound result is displayed above)" + : ". Not the current answer."} +
    + Sent {attempt.request.method} {attempt.request.url}, {attempt.request.byteLength} bytes, + SHA-256 {attempt.request.requestSha256}. +
    + {outcomeLine(attempt, isCurrent)} +
  • + ); +} + +export function Inspector({ + display, + session, +}: { + readonly display: DisplayView; + readonly session: SessionRecord | null; +}) { + const newest = session?.attempts.at(-1) ?? null; + return ( +
    +

    Placement inspector

    + {display.kind === "bound" && newest?.outcome?.type === "result" ? ( +
    +

    Current result

    +

    + Everything below is quoted from the newest attempt's placement response. This app adds + no opinion of its own: the decision word is the result's own claim, not an assurance by + this page. +

    + +
    + ) : ( +

    + No bound result is displayed. The newest attempt's state: {display.kind}. The attempt + history below is still the record of what happened. +

    + )} + {session === null ? null : ( + <> +

    Attempt history

    +

    + Every attempt is recorded with the receipts of what was sent and received. These entries + are history, not answers: only the newest attempt's bound result is ever presented as + current, and an entry here never promotes itself to one. +

    + {session.attempts.length === 0 ? ( +

    No attempts have run in this session.

    + ) : ( +
      + {session.attempts.map((attempt) => ( + + ))} +
    + )} + {session.witnesses.length === 0 ? null : ( + <> +

    Witnesses

    +

    + Answers and failures that were seen but not applied, with the reason. A witness is a + record for review; it is never displayed as an answer. +

    +
      + {session.witnesses.map((witness) => ( +
    • + {witnessLabel(witness.kind)} (attempt{" "} + {shortAttemptId(witness.attemptId)}): {witnessDetail(witness)} +
    • + ))} +
    + + )} + + )} +
    + ); +} diff --git a/web/src/components/SessionPanel.tsx b/web/src/components/SessionPanel.tsx new file mode 100644 index 000000000..34b625c46 --- /dev/null +++ b/web/src/components/SessionPanel.tsx @@ -0,0 +1,90 @@ +// The session record controls: reopen the browser-local saved session, discard it, +// export the current session as redacted JSON, and import a previously exported one. +// The panel states the redaction boundary where the record is offered: the saved and +// exported shapes carry no credential field at all, and reopening never restores a +// bearer token (tokens are in-memory for the page's lifetime only). + +import type { SceneIntakeIssue } from "../lib/contract/scene.ts"; +import type { SavedSessionSummary } from "../lib/session/persist.ts"; +import type { SceneFileInput } from "../lib/session/store.ts"; + +interface SessionPanelProps { + readonly saved: SavedSessionSummary | null; + readonly restoreError: SceneIntakeIssue | null; + readonly storageWarning: string | null; + readonly canExport: boolean; + readonly busy: boolean; + readonly onReopen: () => void; + readonly onDiscard: () => void; + readonly onExport: () => void; + readonly onImport: (file: SceneFileInput) => void; +} + +export function SessionPanel({ + saved, + restoreError, + storageWarning, + canExport, + busy, + onReopen, + onDiscard, + onExport, + onImport, +}: SessionPanelProps) { + return ( +
    + Saved session +

    + While a session is open, this browser keeps its record locally (scene bytes, attempts, + receipts, witnesses) so it can be reopened after a reload. The record holds no bearer token + and no headers — reopening never restores a token. +

    + {storageWarning === null ? null :

    {storageWarning}

    } + {restoreError === null ? null : ( +

    + The record was refused: {restoreError.message} ({restoreError.code}) + {restoreError.path === null ? null : ( + <> + {" "} + At {restoreError.path}. + + )} +

    + )} + {saved === null ? null : ( +

    + A saved session exists: {saved.label}, {saved.attemptCount} attempt + {saved.attemptCount === 1 ? "" : "s"}, saved {new Date(saved.savedAt).toISOString()}. +

    + )} +

    + {saved === null ? null : ( + <> + {" "} + {" "} + + )} + {" "} + {" "} + { + const file = event.target.files?.[0]; + event.currentTarget.value = ""; + if (file !== undefined) { + onImport(file); + } + }} + /> +

    +
    + ); +} diff --git a/web/src/components/inspector.test.tsx b/web/src/components/inspector.test.tsx new file mode 100644 index 000000000..e7d0f1030 --- /dev/null +++ b/web/src/components/inspector.test.tsx @@ -0,0 +1,223 @@ +// Inspector render tests: the component is rendered with react-dom/server against +// snapshots produced by driving the session store through the contract fake — the +// only server surface here (no real network, no DOM). The assertions check what the +// page would actually show: every section of the newest bound result quoted from the +// response, the attempt history labelled as history, and witnesses kept visibly +// separate from the current answer — never promoted to it. + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { createContractFake, RULES_SHA256, sceneText } from "../lib/contract/contract-fake.ts"; +import { sha256HexOfText } from "../lib/contract/hash.ts"; +import type { SessionRecord } from "../lib/contract/session.ts"; +import { type DisplayView, type SceneFileInput, SessionStore } from "../lib/session/store.ts"; +import { Inspector } from "./Inspector.tsx"; + +function fileNamed(name: string, text: string = sceneText()): SceneFileInput { + return { name, text: async () => text }; +} + +/** + * One session carrying the witnesses the inspector must keep separate: a refusal, a + * mismatched answer, an unreadable 200 body, a bound answer, and a superseded late + * arrival — ending on a bound attempt so the display has a current result. + */ +async function sessionWithWitnessHistory(): Promise<{ + display: DisplayView & { readonly kind: "bound" }; + session: SessionRecord | null; +}> { + let current = createContractFake({ + behavior: { + kind: "refused", + status: 422, + code: "policy_violation", + message: "No placement for this scene.", + path: null, + }, + }); + const fetchImpl: typeof fetch = (input, init) => current.fetch(input, init); + const store = new SessionStore({ fetchImpl, storage: null }); + await store.selectSceneFile(fileNamed("scene.json")); + + await store.submit(); // 1: refused + current = createContractFake({ behavior: { kind: "mismatched_sha" } }); + await store.submit(); // 2: mismatched answer, witnessed + current = createContractFake({ behavior: { kind: "unparseable_200" } }); + await store.submit(); // 3: unreadable 200 body, witnessed + current = createContractFake(); + await store.submit(); // 4: bound — the current answer + current = createContractFake({ behavior: { kind: "delayed_then_abort", delayMs: 40 } }); + const late = store.submit(); // 5: superseded mid-flight by 6 + current = createContractFake(); + await store.submit(); // 6: bound — still the newest + await late; // the late outcome lands: witnessed as stale + + const snap = store.getSnapshot(); + if (snap.display.kind !== "bound") throw new Error("the final attempt should be bound"); + return { display: snap.display, session: snap.session }; +} + +describe("inspector renders the newest bound result from the response", () => { + it("shows the decision as the result's own claim, with checks, spot, route, ends and policy", async () => { + const snap = await sessionWithWitnessHistory(); + const html = renderToStaticMarkup(); + + // The decision is quoted, attributed to the result, once. + expect(html).toContain("Placement inspector"); + expect(html).toContain("The result's own decision"); + expect(html.match(/The result's own decision/gu)?.length).toBe(1); + expect(html).toContain("pass"); + expect(html).toContain( + "Summary: The north wall fits a battery between 3.0 ft and 5.5 ft from the meter.", + ); + + // Reasons with their codes. + expect(html).toContain("all_checks_pass"); + expect(html).toContain("Every check at the chosen spot passes with the observed evidence."); + + // The check: label, id, outcome, reason, measurement vs threshold, subject, rule citation. + expect(html).toContain("Gas clearance"); + expect(html).toContain("gas_clearance"); + expect(html).toContain( + "The nearest gas meter is 2.0 ft away, 1.0 ft past the required clearance.", + ); + expect(html).toContain("Measured 2 ft ± 0.1 ft against a minimum of 1 ft."); + expect(html).toContain("objects[0] gas_meter"); + expect(html).toContain("clearances.gas_ft"); + expect(html).toContain("synthetic fixture value"); + expect(html).toContain("(placeholder value with no public source)"); + + // The spot summary when present. + expect(html).toContain("Wall north segment 0"); + expect(html).toContain("span 3 ft to 5.5 ft"); + expect(html).toContain("width 2.5 ft, depth 1.2 ft, height 2 ft"); + expect(html).toContain("center [4.25, 0.6]"); + expect(html).toContain("route 4.25 ft"); + + // The route summary when present. + expect(html).toContain("Length 4.25 ft ± 0.3 ft, height 1 ft"); + + // The ends, with their points. + expect(html).toContain("Left end: limit at -10 ft (point [-10, 0])"); + expect(html).toContain("right end: unexplored at 10 ft (point [10, 0])"); + + // The policy block: id, auto-approve, sources, rules hash, notice. + expect(html).toContain("public-demo"); + expect(html).toContain("auto-approve on"); + expect(html).toContain("sources public"); + expect(html).toContain(RULES_SHA256); + expect(html).toContain("Demo rules, not the utility's."); + + // Statistics, bound to the upload's own hash. + expect(html).toContain("1 candidate: 1 pass, 0 unsure, 0 fail, in 12 ms"); + expect(html).toContain(await sha256HexOfText(sceneText())); + }); + + it("renders missing-evidence entries with their band, span and referenced checks", async () => { + const snap = await sessionWithWitnessHistory(); + const withMissing = { + ...snap.display, + result: { + ...snap.display.result, + missing_evidence: [ + { + kind: "band" as const, + band: "ground" as const, + span_ft: [-6, 2] as [number, number], + out_ft: 4, + checks: ["clearance_gas_ft", "setback_ft"], + message: "The ground beyond 6 ft was not observed; the span there is unverified.", + }, + { + kind: "past_end" as const, + band: "overhead" as const, + side: "left" as const, + message: "No overhead clearance was observed on the left end.", + }, + ], + }, + }; + const html = renderToStaticMarkup(); + expect(html).toContain("Missing evidence"); + expect(html).toContain("band band ground"); + expect(html).toContain("span -6 ft to 2 ft"); + expect(html).toContain("out to 4 ft"); + expect(html).toContain("checks: clearance_gas_ft, setback_ft"); + expect(html).toContain( + "The ground beyond 6 ft was not observed; the span there is unverified.", + ); + expect(html).toContain("past_end band overhead, left side"); + expect(html).toContain("No overhead clearance was observed on the left end."); + }); + + it("says so when the response has no spot or no route instead of filling the gap", async () => { + const snap = await sessionWithWitnessHistory(); + const emptyish = { + ...snap.display, + result: { ...snap.display.result, spot: null, route: null, nearest_considered: null }, + }; + const html = renderToStaticMarkup(); + expect(html).toContain("No spot fits the observed evidence."); + expect(html).toContain("No route was produced."); + }); +}); + +describe("inspector keeps history and witnesses separate from the current answer", () => { + it("records refused, mismatched, unreadable and stale attempts without promoting any of them", async () => { + const snap = await sessionWithWitnessHistory(); + const html = renderToStaticMarkup(); + + // The attempt history block exists and labels itself as history. + expect(html).toContain("Attempt history"); + expect(html).toContain("These entries are history, not answers"); + + // Each non-current attempt is explicitly not the current answer. + expect(html.match(/Not the current answer\./gu)?.length).toBe(5); + + // The refusal: status phrase, the server's code and message. + expect(html).toContain("refused by the server"); + expect(html).toContain("policy_violation"); + expect(html).toContain("No placement for this scene."); + + // The mismatched answer: recorded with both hashes, never displayed as a result. + expect(html).toContain( + "mismatched — the answer named different bytes; recorded, never displayed", + ); + expect(html).toContain("kept as a witness, never displayed"); + expect(html).toContain("mismatched result"); + expect(html).toContain("the request sent"); + expect(html).toContain("the answer claims"); + + // The unreadable 200 body. + expect(html).toContain("failed"); + expect(html).toContain("unreadable 200 body"); + expect(html).toContain("not_json"); + + // The superseded late arrival: its outcome is history, the newer attempt is current. + expect(html).toContain("superseded by a newer attempt"); + expect(html).toContain("late arrival for a superseded attempt"); + + // The witnesses block: present, with the non-promotion rule stated. + expect(html).toContain("Witnesses"); + expect(html).toContain("it is never displayed as an answer"); + + // Exactly one decision quote — the current result's own claim. + expect(html.match(/The result's own decision/gu)?.length).toBe(1); + expect(html).toContain("the attempt whose bound result is displayed above"); + }); + + it("shows the history with no bound result when the newest attempt never produced one", async () => { + const fake = createContractFake({ behavior: { kind: "unparseable_200" } }); + const store = new SessionStore({ fetchImpl: fake.fetch, storage: null }); + await store.selectSceneFile(fileNamed("scene.json")); + await store.submit(); + const snap = store.getSnapshot(); + expect(snap.display.kind).toBe("unparseable_result"); + const html = renderToStaticMarkup(); + expect(html).toContain("No bound result is displayed."); + expect(html).toContain("The attempt history below is still the record of what happened."); + expect(html).toContain("unreadable 200 body"); + // And no decision quote exists anywhere: there is no result to quote. + expect(html).not.toContain("The result's own decision"); + }); +}); diff --git a/web/src/lib/session/persist.test.ts b/web/src/lib/session/persist.test.ts new file mode 100644 index 000000000..a4a7d1a2f --- /dev/null +++ b/web/src/lib/session/persist.test.ts @@ -0,0 +1,321 @@ +// Persistence and redaction tests: the store bound to the contract fake (the only +// server surface here — no real network) and an in-memory stand-in for localStorage. +// Each test drives the operations the page does and asserts on what actually lands in +// storage and what comes back out: the roundtrip must restore attempts, receipts, +// witnesses and the displayed result, must never carry a credential, and a doctored +// record must fail closed instead of displaying what it claims. + +import { describe, expect, it } from "vitest"; +import { + type ContractFake, + createContractFake, + MISMATCHED_SHA256, + sceneText, +} from "../contract/contract-fake.ts"; +import { sha256HexOfText } from "../contract/hash.ts"; +import { + PERSIST_FORMAT, + PERSIST_VERSION, + parseSessionPayload, + SESSION_STORAGE_KEY, +} from "./persist.ts"; +import { type SceneFileInput, SessionStore } from "./store.ts"; + +function fileNamed(name: string, text: string = sceneText()): SceneFileInput { + return { name, text: async () => text }; +} + +function must(value: T | null | undefined, what: string): T { + if (value === null || value === undefined) throw new Error(`test setup lost ${what}`); + return value; +} + +/** In-memory stand-in for localStorage, with the map exposed for direct assertions. */ +function memoryPersistence(): { + readonly storage: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; + }; + readonly map: Map; +} { + const map = new Map(); + return { + map, + storage: { + getItem: (key) => map.get(key) ?? null, + setItem: (key, value) => { + map.set(key, value); + }, + removeItem: (key) => { + map.delete(key); + }, + }, + }; +} + +/** Depth-first key scan, so a credential cannot hide in a nested block of a record. */ +function findKeyMatching(value: unknown, needle: RegExp, path = ""): string | null { + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) { + const hit = findKeyMatching(item, needle, `${path}[${index}]`); + if (hit !== null) return hit; + } + return null; + } + if (typeof value === "object" && value !== null) { + for (const [key, item] of Object.entries(value)) { + const keyPath = path === "" ? key : `${path}.${key}`; + if (needle.test(key)) return keyPath; + const hit = findKeyMatching(item, needle, keyPath); + if (hit !== null) return hit; + } + } + return null; +} + +/** First store with a fresh bound session; returns the store, its raw saved bytes and the fake. */ +async function boundStoreWith(token: string): Promise<{ + store: SessionStore; + raw: string; + fake: ContractFake; + persistence: ReturnType; +}> { + const fake = createContractFake(); + const persistence = memoryPersistence(); + const store = new SessionStore({ fetchImpl: fake.fetch, storage: persistence.storage }); + store.setToken(token); + await store.selectSceneFile(fileNamed("scene.json")); + await store.submit(); + expect(store.getSnapshot().display.kind).toBe("bound"); + const raw = must(persistence.map.get(SESSION_STORAGE_KEY), "the saved record"); + return { store, raw, fake, persistence }; +} + +describe("browser-local persistence", () => { + it("saves the redacted record after every change and never stores the token or headers", async () => { + const { store, raw } = await boundStoreWith("sekrit-reopen-token"); + // The record carries the scene's exact text (as the escaped string of the stored + // JSON) and the attempt history... + expect(raw).toContain(PERSIST_FORMAT); + expect(raw).toContain(JSON.stringify(sceneText()).slice(1, -1)); + expect(raw).toContain("requestSha256"); + // ...and no trace of the credential, by field or by value. + expect(raw).not.toContain("sekrit-reopen-token"); + const parsed: unknown = JSON.parse(raw); + expect(findKeyMatching(parsed, /token|authorization|bearer|cookie/i)).toBeNull(); + // The store's snapshot stays clean too (the token lives in memory only). + expect(JSON.stringify(store.getSnapshot())).not.toContain("sekrit-reopen-token"); + }); + + it("reopens the saved session with the display intact and can submit a fresh attempt", async () => { + const token = "sekrit-reopen-token"; + const { store: first, raw, fake, persistence } = await boundStoreWith(token); + const firstSession = must(first.getSnapshot().session, "the bound session"); + const firstScene = must(first.getSnapshot().scene, "the scene summary"); + + // A brand-new store over the same storage — what a page reload sees. + const reopened = new SessionStore({ fetchImpl: fake.fetch, storage: persistence.storage }); + const before = reopened.getSnapshot(); + expect(before.savedSession).not.toBeNull(); + expect(before.savedSession?.attemptCount).toBe(1); + expect(before.savedSession?.sceneFileName).toBe("scene.json"); + expect(before.display.kind).toBe("no_scene"); // Nothing is shown until the user reopens. + + await reopened.reopenSession(); + const snap = reopened.getSnapshot(); + expect(snap.display.kind).toBe("bound"); + if (snap.display.kind !== "bound") return; + expect(snap.display.result.decision).toBe("pass"); + expect(snap.hasToken).toBe(false); // The token is never restored. + expect(snap.scene?.sha256).toBe(firstScene.sha256); + expect(snap.scene?.sha256).toBe(await sha256HexOfText(sceneText())); + // The whole record comes back equal: attempts, receipts, outcome, witnesses. + expect(snap.session).toEqual(firstSession); + // The opened session is now the live one; a new attempt starts and binds. + await reopened.submit(); + const after = reopened.getSnapshot(); + expect(after.display.kind).toBe("bound"); + expect(after.session?.attempts).toHaveLength(2); + expect(after.session?.attempts[0]?.status).toBe("bound"); // history kept + // The saved record still carries no credential after the new attempt. + expect(must(raw, "raw")).not.toContain(token); + }); + + it("reopens an interrupted in-flight attempt as cancelled, keeping its receipt, and can bind afterwards", async () => { + const persistence = memoryPersistence(); + const hanging: typeof fetch = () => new Promise(() => {}); // never settles + const stuck = new SessionStore({ fetchImpl: hanging, storage: persistence.storage }); + await stuck.selectSceneFile(fileNamed("scene.json")); + const inFlight = stuck.submit(); // published synchronously: the record now says "submitting" + const raw = must(persistence.map.get(SESSION_STORAGE_KEY), "the mid-flight record"); + const stored = must(JSON.parse(raw), "the stored record") as { + session: { attempts: { status: unknown }[] }; + }; + const attempt = must(stored.session.attempts[0], "the mid-flight attempt"); + expect(attempt.status).toBe("submitting"); + + // The page reloads; the reopened record settles what the data can mean. + const fake = createContractFake(); + const reopened = new SessionStore({ fetchImpl: fake.fetch, storage: persistence.storage }); + await reopened.reopenSession(); + const snap = reopened.getSnapshot(); + expect(snap.display.kind).toBe("cancelled"); // No bound answer was ever received; + // the newest (interrupted) attempt is what the display reports. + const attemptRestored = must(snap.session?.attempts[0], "the restored attempt"); + expect(attemptRestored.status).toBe("cancelled"); + expect(attemptRestored.outcome?.type).toBe("cancelled"); + expect(attemptRestored.request.requestSha256).toBe(await sha256HexOfText(sceneText())); + void inFlight; + + // A fresh attempt on the reopened session still binds. + await reopened.submit(); + expect(reopened.getSnapshot().display.kind).toBe("bound"); + expect(reopened.getSnapshot().session?.attempts).toHaveLength(2); + }); + + it("reports a corrupt saved record, keeps the bytes, and discard clears the slot", () => { + const persistence = memoryPersistence(); + persistence.map.set(SESSION_STORAGE_KEY, "this is not the record you are looking for"); + const store = new SessionStore({ + fetchImpl: createContractFake().fetch, + storage: persistence.storage, + }); + const snap = store.getSnapshot(); + expect(snap.savedSession).toBeNull(); + expect(snap.storageWarning).toContain("could not be read"); + + store.discardSavedSession(); + expect(persistence.map.has(SESSION_STORAGE_KEY)).toBe(false); + }); +}); + +describe("restore refuses records it cannot trust", () => { + it("rejects malformed JSON, foreign formats and unsupported versions", async () => { + const { raw } = await boundStoreWith("t"); + const payload = must(JSON.parse(raw) as Record, "payload"); + expect(parseSessionPayload("not json at all")).toMatchObject({ + ok: false, + failure: { code: "invalid_json" }, + }); + expect( + parseSessionPayload(JSON.stringify({ ...payload, format: "someone-elses-export" })), + ).toMatchObject({ + ok: false, + failure: { code: "wrong_format" }, + }); + expect( + parseSessionPayload(JSON.stringify({ ...payload, version: PERSIST_VERSION + 1 })), + ).toMatchObject({ + ok: false, + failure: { code: "wrong_version" }, + }); + expect(parseSessionPayload("{}")).toMatchObject({ + ok: false, + failure: { code: "wrong_format" }, + }); + }); + + it("rejects an attempt history this build cannot read", async () => { + const { raw } = await boundStoreWith("t"); + const payload = must(JSON.parse(raw) as Record, "payload"); + const session = must(payload.session as Record, "session"); + const attempts = must(session.attempts as unknown[], "attempts"); + const attempt = must(attempts[0] as Record, "attempt"); + + expect( + parseSessionPayload( + JSON.stringify({ + ...payload, + session: { ...session, attempts: [{ ...attempt, outcome: { type: "surprise" } }] }, + }), + ), + ).toMatchObject({ ok: false, failure: { code: "invalid_attempt" } }); + expect( + parseSessionPayload( + JSON.stringify({ + ...payload, + session: { ...session, witnesses: [{ kind: "surprise" }] }, + }), + ), + ).toMatchObject({ ok: false, failure: { code: "invalid_witness" } }); + }); + + it("re-derives the status from the data: a doctored 'bound' label on a mismatched answer cannot display it", async () => { + // A session whose answer named other bytes: recorded as mismatched, never displayed. + const mismatched = createContractFake({ behavior: { kind: "mismatched_sha" } }); + const persistence = memoryPersistence(); + const source = new SessionStore({ fetchImpl: mismatched.fetch, storage: persistence.storage }); + await source.selectSceneFile(fileNamed("scene.json")); + await source.submit(); + expect(source.getSnapshot().display.kind).toBe("mismatched"); + const raw = must(persistence.map.get(SESSION_STORAGE_KEY), "the mismatched record"); + + // Doctor the stored label to claim the display for the mismatched answer. + const payload = must(JSON.parse(raw) as Record, "payload"); + const session = must(payload.session as Record, "session"); + const attempts = must(session.attempts as unknown[], "attempts"); + const attempt = must(attempts[0] as Record, "attempt"); + attempt.status = "bound"; + expect(attempt.outcome).not.toBeNull(); + + // The restore path also re-derives the result's own binding: the doctored hash + // cannot promote it either. + const outcome = must(attempt.outcome as Record, "outcome"); + const result = must(outcome.result as Record, "result"); + const stats = must(result.stats as Record, "stats"); + stats.input_sha256 = MISMATCHED_SHA256; // already lies; still re-derived on restore + + const store = new SessionStore({ fetchImpl: createContractFake().fetch, storage: null }); + await store.importSession( + JSON.stringify({ ...payload, session: { ...session, attempts: [attempt] } }), + ); + const snap = store.getSnapshot(); + expect(snap.display.kind).toBe("mismatched"); // shown as what it is — not as the answer + expect(snap.restoreError).toBeNull(); + const restored = must(snap.session?.attempts[0], "the restored attempt"); + expect(restored.status).toBe("mismatched"); + expect(snap.session?.currentResult).toBeNull(); + expect(snap.session?.witnesses.length).toBeGreaterThan(0); + }); +}); + +describe("redacted export and import", () => { + it("exports a credential-free record whose import restores an equal session", async () => { + const token = "sekrit-export-token"; + const { store: first } = await boundStoreWith(token); + const firstSession = must(first.getSnapshot().session, "the bound session"); + const exported = must(first.exportSession(), "the export"); + expect(exported).not.toContain(token); + expect(findKeyMatching(JSON.parse(exported), /token|authorization|bearer|cookie/i)).toBeNull(); + + const second = new SessionStore({ fetchImpl: createContractFake().fetch, storage: null }); + await second.importSession(exported); + const snap = second.getSnapshot(); + expect(snap.display.kind).toBe("bound"); + if (snap.display.kind !== "bound") return; + expect(snap.display.result.decision).toBe("pass"); + expect(snap.scene?.sha256).toBe(first.getSnapshot().scene?.sha256); + expect(snap.session).toEqual(firstSession); + expect(snap.hasToken).toBe(false); + }); + + it("refuses an unusable import and leaves the current session untouched", async () => { + const { store } = await boundStoreWith("t"); + const sessionBefore = store.getSnapshot().session; + + await store.importSession("{ definitely not the record"); + expect(store.getSnapshot().restoreError?.code).toBe("invalid_json"); + expect(store.getSnapshot().session).toEqual(sessionBefore); + expect(store.getSnapshot().display.kind).toBe("bound"); + + await store.importSession(JSON.stringify({ hello: "world" })); + expect(store.getSnapshot().restoreError?.code).toBe("wrong_format"); + expect(store.getSnapshot().session).toEqual(sessionBefore); + }); + + it("returns no export when there is no session", () => { + const store = new SessionStore({ fetchImpl: createContractFake().fetch, storage: null }); + expect(store.exportSession()).toBeNull(); + }); +}); diff --git a/web/src/lib/session/persist.ts b/web/src/lib/session/persist.ts new file mode 100644 index 000000000..98a277eba --- /dev/null +++ b/web/src/lib/session/persist.ts @@ -0,0 +1,550 @@ +// Session persistence and the redacted export/import record. One payload shape serves +// both the browser-local storage slot and the user-initiated JSON export: +// +// { format, version, savedAt, scene: { fileName, text }, session: SessionRecord } +// +// The scene's exact text is the payload's byte carrier: re-intaking it reproduces the +// upload bytes and their SHA-256 deterministically, so a reopened session can submit a +// new attempt bound to the same bytes. +// +// The redaction boundary is structural, not a filter: this payload type has no field a +// credential could occupy. The bearer token lives in the store's private memory only +// (never in any snapshot or receipt), so nothing here can ever write it — there is no +// path for it. Receipt URLs keep the server origin (they are the evidence of what was +// sent); the client never records request or response headers at all. +// +// Restore is defensive: parseSessionPayload validates and REBUILDS the payload from +// known fields only (unknown fields are dropped, so a hostile or hand-edited export +// cannot smuggle state in), re-intakes the scene through the same intake a fresh file +// takes, and rehydrates the session: an attempt recorded as "submitting" can never +// settle (the page closed mid-flight), so it restores as cancelled, and currentResult +// is recomputed from the attempts instead of trusting the stored field. + +import type { PlacementFailure } from "../contract/errors.ts"; +import type { ResultBinding } from "../contract/result.ts"; +import { + bindResultToRequest, + intakeResultBody, + type ResultIntakeFailure, +} from "../contract/result.ts"; +import type { + AttemptOutcome, + RequestReceipt, + ResponseReceipt, + SessionRecord, + SessionWitness, + SubmissionAttempt, + SubmissionStatus, +} from "../contract/session.ts"; + +/** The storage slot this app uses for its one saved session. */ +export const SESSION_STORAGE_KEY = "basescanning-web.session.v1"; + +export const PERSIST_FORMAT = "basescanning-session-record"; +export const PERSIST_VERSION = 1; + +/** The redacted record: the only shape storage and the export ever carry. */ +export interface PersistedSessionPayload { + readonly format: typeof PERSIST_FORMAT; + readonly version: typeof PERSIST_VERSION; + readonly savedAt: number; + readonly scene: { + readonly fileName: string; + /** The file's exact text — re-intaking it reproduces the upload bytes and hash. */ + readonly text: string; + }; + readonly session: SessionRecord; +} + +/** What the page shows about a saved record before the user chooses to reopen it. */ +export interface SavedSessionSummary { + readonly sessionId: string; + readonly label: string; + readonly sceneFileName: string; + readonly attemptCount: number; + readonly savedAt: number; +} + +/** The storage surface the store persists through; localStorage satisfies this. */ +export interface SessionPersistence { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** localStorage when the environment has one, null where persistence is unavailable. */ +export function defaultSessionPersistence(): SessionPersistence | null { + try { + return typeof globalThis.localStorage === "undefined" ? null : globalThis.localStorage; + } catch { + return null; + } +} + +export function buildSessionPayload( + fileName: string, + sceneText: string, + session: SessionRecord, + savedAt: number = Date.now(), +): PersistedSessionPayload { + return { + format: PERSIST_FORMAT, + version: PERSIST_VERSION, + savedAt, + scene: { fileName, text: sceneText }, + session, + }; +} + +export function savedSessionSummaryOf(payload: PersistedSessionPayload): SavedSessionSummary { + return { + sessionId: payload.session.sessionId, + label: payload.session.label, + sceneFileName: payload.scene.fileName, + attemptCount: payload.session.attempts.length, + savedAt: payload.savedAt, + }; +} + +export interface RestoreFailure { + readonly code: + | "invalid_json" + | "wrong_format" + | "wrong_version" + | "invalid_scene_block" + | "invalid_session_block" + | "invalid_attempt" + | "invalid_outcome" + | "invalid_witness"; + readonly message: string; +} + +export type ParseResult = + | { readonly ok: true; readonly payload: PersistedSessionPayload } + | { readonly ok: false; readonly failure: RestoreFailure }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isSha256Hex(value: unknown): value is string { + return typeof value === "string" && /^[0-9a-f]{64}$/.test(value); +} + +function requestReceiptOf(raw: unknown): RequestReceipt | null { + if (!isRecord(raw)) return null; + const attemptId = raw.attemptId; + const url = raw.url; + const method = raw.method; + const contentType = raw.contentType; + if ( + typeof attemptId !== "string" || + typeof url !== "string" || + typeof method !== "string" || + typeof contentType !== "string" || + !isFiniteNumber(raw.byteLength) || + !isSha256Hex(raw.requestSha256) || + !isFiniteNumber(raw.startedAt) + ) { + return null; + } + return { + attemptId, + url, + method, + contentType, + byteLength: raw.byteLength, + requestSha256: raw.requestSha256, + startedAt: raw.startedAt, + }; +} + +function responseReceiptOf(raw: unknown): ResponseReceipt | null { + if (!isRecord(raw)) return null; + if ( + typeof raw.attemptId !== "string" || + !isFiniteNumber(raw.status) || + typeof raw.contentType !== "string" || + !isFiniteNumber(raw.bodyByteLength) || + !(raw.bodySha256 === null || isSha256Hex(raw.bodySha256)) || + !(raw.bodyExcerpt === null || typeof raw.bodyExcerpt === "string") || + typeof raw.bodyTruncated !== "boolean" || + !isFiniteNumber(raw.elapsedMs) || + !isFiniteNumber(raw.finishedAt) + ) { + return null; + } + return { + attemptId: raw.attemptId, + status: raw.status, + contentType: raw.contentType, + bodyByteLength: raw.bodyByteLength, + bodySha256: raw.bodySha256, + bodyExcerpt: raw.bodyExcerpt, + bodyTruncated: raw.bodyTruncated, + elapsedMs: raw.elapsedMs, + finishedAt: raw.finishedAt, + }; +} + +/** Server-side failures an outcome may carry: the envelope, a non-envelope answer, or no answer. */ +function serverFailureOf(raw: unknown): PlacementFailure | null { + if (!isRecord(raw)) return null; + if (raw.kind === "server_error") { + const envelope = raw.envelope; + if ( + !isRecord(envelope) || + typeof envelope.code !== "string" || + typeof envelope.message !== "string" || + !(envelope.path === null || typeof envelope.path === "string") || + !isFiniteNumber(raw.status) || + typeof raw.knownCode !== "boolean" + ) { + return null; + } + return { + kind: "server_error", + status: raw.status, + envelope: { code: envelope.code, message: envelope.message, path: envelope.path }, + knownCode: raw.knownCode, + }; + } + if (raw.kind === "unparseable_error") { + if (!isFiniteNumber(raw.status) || typeof raw.detail !== "string") return null; + return { kind: "unparseable_error", status: raw.status, detail: raw.detail }; + } + if (raw.kind === "network_error") { + return typeof raw.detail === "string" ? { kind: "network_error", detail: raw.detail } : null; + } + return null; +} + +/** The intake failure a recorded "unparseable_result" outcome carries. */ +const INTAKE_FAILURE_REASONS = [ + "not_json", + "not_object", + "wrong_version", + "missing_required", + "wrong_shape", +]; + +function resultIntakeFailureOf(raw: unknown): { reason: string; detail: string } | null { + if ( + !isRecord(raw) || + !INTAKE_FAILURE_REASONS.includes(raw.reason as string) || + typeof raw.detail !== "string" + ) { + return null; + } + return { reason: raw.reason as string, detail: raw.detail }; +} + +/** + * Rebuilds one attempt outcome from the record. A stored result is re-validated by the + * same intake a live 200 body takes (intakeResultBody), and its binding is re-derived + * from the hashes — the data decides "bound" or "mismatched", not the stored label. + */ +function outcomeOf(raw: unknown, requestSha256: string): AttemptOutcome | null { + if (!isRecord(raw)) return null; + switch (raw.type) { + case "result": { + const response = responseReceiptOf(raw.response); + if (response === null) return null; + const intake = intakeResultBody(raw.result); + if (!intake.ok) return null; + const binding: ResultBinding = bindResultToRequest(intake.result, requestSha256); + return { + type: "result", + response, + result: intake.result, + binding, + additiveNotes: intake.additiveNotes, + }; + } + case "unparseable_result": { + const response = responseReceiptOf(raw.response); + const failure = resultIntakeFailureOf(raw.failure); + if (response === null || failure === null) return null; + return { + type: "unparseable_result", + response, + failure: { reason: failure.reason, detail: failure.detail } as ResultIntakeFailure, + }; + } + case "refused": { + const response = responseReceiptOf(raw.response); + const failure = serverFailureOf(raw.failure); + if (response === null || failure === null) return null; + if (failure.kind !== "server_error" && failure.kind !== "unparseable_error") return null; + return { type: "refused", response, failure }; + } + case "failed": { + const failure = serverFailureOf(raw.failure); + if (failure === null) return null; + return { type: "failed", failure }; + } + case "cancelled": + return { type: "cancelled" }; + default: + return null; + } +} + +const STATUSES: readonly SubmissionStatus[] = [ + "submitting", + "bound", + "mismatched", + "refused", + "failed", + "cancelled", + "stale", +]; + +/** + * Re-derives an attempt's status from its outcome, mirroring the live store's + * applyOutcome switch. The stored label is never trusted where it can promote + * something to the display: only the outcome's data decides "bound" or + * "mismatched". A stale label survives only as the "superseded mid-flight" marker + * the live store itself would have left. + */ +function rederiveStatus( + stored: SubmissionStatus, + outcome: AttemptOutcome | null, +): SubmissionStatus | null { + if (outcome === null) { + // No outcome is only consistent with an attempt still mid-flight ("submitting"), + // one superseded before an answer arrived ("stale"), or an already-cancelled one. + if (stored === "submitting" || stored === "stale" || stored === "cancelled") { + return stored; + } + return null; + } + switch (outcome.type) { + case "result": + if (outcome.binding.kind !== "bound") return "mismatched"; + return stored === "stale" ? "stale" : "bound"; + case "unparseable_result": + return "failed"; + case "refused": + return "refused"; + case "failed": + return "failed"; + case "cancelled": + return "cancelled"; + } +} + +function attemptOf(raw: unknown): SubmissionAttempt | null { + if (!isRecord(raw)) return null; + const request = requestReceiptOf(raw.request); + if (request === null) return null; + if (typeof raw.attemptId !== "string" || !isFiniteNumber(raw.startedAt)) return null; + if (!STATUSES.includes(raw.status as SubmissionStatus)) return null; + const storedStatus = raw.status as SubmissionStatus; + // A "submitting" attempt is mid-flight by definition: a record that claims an outcome + // for it is inconsistent and refused rather than guessed at. + if (storedStatus === "submitting" && raw.outcome !== null) return null; + let outcome: AttemptOutcome | null = null; + if (raw.outcome !== null) { + outcome = outcomeOf(raw.outcome, request.requestSha256); + if (outcome === null) return null; + } + const status = rederiveStatus(storedStatus, outcome); + if (status === null) return null; + return { + attemptId: raw.attemptId, + startedAt: raw.startedAt, + requestSha256: request.requestSha256, + request, + status, + outcome, + }; +} + +function witnessOf(raw: unknown): SessionWitness | null { + if (!isRecord(raw)) return null; + switch (raw.kind) { + case "mismatched_result": + if ( + typeof raw.attemptId !== "string" || + !isSha256Hex(raw.requestSha256) || + !isSha256Hex(raw.resultSha256) + ) { + return null; + } + return { + kind: "mismatched_result", + attemptId: raw.attemptId, + requestSha256: raw.requestSha256, + resultSha256: raw.resultSha256, + }; + case "stale_outcome": + if (typeof raw.attemptId !== "string" || typeof raw.newestAttemptId !== "string") return null; + return { + kind: "stale_outcome", + attemptId: raw.attemptId, + newestAttemptId: raw.newestAttemptId, + }; + case "unparseable_result": + if (typeof raw.attemptId !== "string" || typeof raw.reason !== "string") return null; + return { kind: "unparseable_result", attemptId: raw.attemptId, reason: raw.reason }; + case "refused": + if ( + typeof raw.attemptId !== "string" || + typeof raw.code !== "string" || + typeof raw.detail !== "string" + ) { + return null; + } + return { kind: "refused", attemptId: raw.attemptId, code: raw.code, detail: raw.detail }; + default: + return null; + } +} + +/** + * Parses stored or exported text into the payload, rebuilding every known field and + * dropping everything else. The scene text is checked for shape only here; the full + * scene intake runs at restore time and reports precise refusals. + */ +export function parseSessionPayload(raw: string): ParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch (error) { + return { + ok: false, + failure: { + code: "invalid_json", + message: `The record is not valid JSON: ${(error as Error).message}`, + }, + }; + } + if (!isRecord(parsed) || parsed.format !== PERSIST_FORMAT) { + return { + ok: false, + failure: { + code: "wrong_format", + message: `The record is not a ${PERSIST_FORMAT} payload.`, + }, + }; + } + if (parsed.version !== PERSIST_VERSION) { + return { + ok: false, + failure: { + code: "wrong_version", + message: `The record names version ${JSON.stringify(parsed.version)}; this build reads ${PERSIST_VERSION} only.`, + }, + }; + } + const scene = parsed.scene; + if (!isRecord(scene) || typeof scene.fileName !== "string" || typeof scene.text !== "string") { + return { + ok: false, + failure: { + code: "invalid_scene_block", + message: "The record's scene block is missing a file name or the scene's exact text.", + }, + }; + } + const stored = parsed.session; + if ( + !isRecord(stored) || + typeof stored.sessionId !== "string" || + stored.sessionId.length === 0 || + typeof stored.label !== "string" || + !isFiniteNumber(stored.createdAt) || + !isSha256Hex(stored.sceneSha256) || + !Array.isArray(stored.attempts) || + !Array.isArray(stored.witnesses) + ) { + return { + ok: false, + failure: { + code: "invalid_session_block", + message: + "The record's session block is missing its id, label, timestamps, attempts or witnesses.", + }, + }; + } + const attempts: SubmissionAttempt[] = []; + for (const rawAttempt of stored.attempts) { + const attempt = attemptOf(rawAttempt); + if (attempt === null) { + return { + ok: false, + failure: { + code: "invalid_attempt", + message: "The record's attempt history contains an entry this build cannot read.", + }, + }; + } + attempts.push(attempt); + } + const witnesses: SessionWitness[] = []; + for (const rawWitness of stored.witnesses) { + const witness = witnessOf(rawWitness); + if (witness === null) { + return { + ok: false, + failure: { + code: "invalid_witness", + message: "The record's witness list contains an entry this build cannot read.", + }, + }; + } + witnesses.push(witness); + } + return { + ok: true, + payload: { + format: PERSIST_FORMAT, + version: PERSIST_VERSION, + savedAt: isFiniteNumber(parsed.savedAt) ? parsed.savedAt : 0, + scene: { fileName: scene.fileName, text: scene.text }, + // currentResult is deliberately not read from the record: restore recomputes it + // from the attempts (see rehydrateSession), so a stale or doctored field is inert. + session: { + sessionId: stored.sessionId, + label: stored.label, + createdAt: stored.createdAt, + sceneSha256: stored.sceneSha256, + attempts, + currentResult: null, + witnesses, + }, + }, + }; +} + +/** + * Turns a validated payload into a live SessionRecord. Two normalizations, both honest + * restatements of what the data can mean: an attempt that was mid-flight when the page + * closed can never receive its outcome, so it restores as cancelled (its request + * receipt stays); and the displayed result is recomputed from the newest attempt, so + * the record's stored currentResult field is never trusted. + */ +export function rehydrateSession(payload: PersistedSessionPayload): SessionRecord { + const attempts = payload.session.attempts.map((attempt) => + attempt.status === "submitting" + ? { + ...attempt, + status: "cancelled" as const, + // The interrupted attempt gains the same cancelled outcome the live + // cancel path records (applyOutcome), so history reads the same either way. + outcome: { type: "cancelled" } as const, + } + : attempt, + ); + const newest = attempts.at(-1); + const currentResult = + newest !== undefined && newest.status === "bound" && newest.outcome?.type === "result" + ? newest.outcome.result + : null; + return { ...payload.session, attempts, currentResult }; +} diff --git a/web/src/lib/session/store.ts b/web/src/lib/session/store.ts index 82811f0a1..e7afb8fe8 100644 --- a/web/src/lib/session/store.ts +++ b/web/src/lib/session/store.ts @@ -20,6 +20,16 @@ // the attempts that produced them — never displayed as the current answer. The // bearer token lives in memory for the next submission only: never persisted, never // logged, absent from every snapshot and receipt. +// +// M3 adds the record's lifecycle beyond the page's lifetime: every change re-saves +// the redacted record browser-locally (scene bytes included; the token and headers +// excluded by construction), the page offers reopening or discarding the saved +// record, and exportSession/importSession move the same redacted shape as JSON. +// Restore re-validates everything — the scene is re-intaked from its exact bytes and +// the current result is re-derived from the attempt history — so a doctored record +// fails closed instead of displaying what its author claimed. Reopening never +// restores a token: a reopened session submits unauthenticated until a new one is +// entered. import { PlacementClient, type SubmitReport } from "../contract/client.ts"; import type { PlacementFailure } from "../contract/errors.ts"; @@ -39,6 +49,16 @@ import { } from "../contract/session.ts"; import type { PlacementResult } from "../contract/types.ts"; import type { SceneVersionReport } from "../contract/versions.ts"; +import { + buildSessionPayload, + defaultSessionPersistence, + parseSessionPayload, + rehydrateSession, + type SavedSessionSummary, + SESSION_STORAGE_KEY, + type SessionPersistence, + savedSessionSummaryOf, +} from "./persist.ts"; export const DEFAULT_BASE_URL = "http://localhost:8000"; @@ -92,12 +112,24 @@ export interface SessionStoreState { readonly intakeError: SceneIntakeIssue | null; readonly session: SessionRecord | null; readonly display: DisplayView; + /** Summary of the browser-local saved record, offered for reopen; null when none. */ + readonly savedSession: SavedSessionSummary | null; + /** Why the last reopen or import failed. The previous session stays untouched. */ + readonly restoreError: SceneIntakeIssue | null; + /** A storage-level problem (quota, corrupt saved record) that is not a restore refusal. */ + readonly storageWarning: string | null; } export interface SessionStoreOptions { /** Injectable transport (tests use the contract fake). Defaults to globalThis.fetch. */ readonly fetchImpl?: typeof fetch; readonly baseUrl?: string; + /** + * Injectable browser-local storage for the saved session (tests use an in-memory + * map). Defaults to localStorage when the environment has one; pass null to disable + * persistence. + */ + readonly storage?: SessionPersistence | null; } /** Maps a client report onto the outcome the session layer applies. */ @@ -217,11 +249,17 @@ export class SessionStore { private sceneSummary: SceneSummary | null = null; private intakeError: SceneIntakeIssue | null = null; private inFlight: AbortController | null = null; + private savedSession: SavedSessionSummary | null = null; + private restoreError: SceneIntakeIssue | null = null; + private storageWarning: string | null = null; + private readonly storage: SessionPersistence | null; private current: SessionStoreState; constructor(options: SessionStoreOptions = {}) { this.fetchImpl = options.fetchImpl ?? fetch; this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL; + this.storage = options.storage === undefined ? defaultSessionPersistence() : options.storage; + this.savedSession = this.loadSavedSummary(); this.current = { baseUrl: this.baseUrl, hasToken: false, @@ -229,6 +267,9 @@ export class SessionStore { intakeError: null, session: null, display: { kind: "no_scene" }, + savedSession: this.savedSession, + restoreError: null, + storageWarning: this.storageWarning, }; } @@ -339,7 +380,166 @@ export class SessionStore { this.inFlight?.abort(); } + /** + * Restores the browser-local saved session: scene bytes re-intaked from the exact + * persisted text (same bytes, same SHA-256), attempts, witnesses and receipts intact, + * so the newest bound result displays again and a new attempt can start. The bearer + * token is never restored — it lives in memory for the app lifetime only, so a + * reopened session submits unauthenticated until a new one is entered. + */ + async reopenSession(): Promise { + if (this.storage === null || this.savedSession === null) { + return; + } + let raw: string | null = null; + try { + raw = this.storage.getItem(SESSION_STORAGE_KEY); + } catch (error) { + this.storageWarning = `The saved session could not be read: ${(error as Error).message}`; + this.savedSession = null; + this.publish(); + return; + } + if (raw === null) { + this.savedSession = null; + this.publish(); + return; + } + await this.restoreFromText(raw); + } + + /** + * Restores a record from JSON text (a user-chosen export file). Failures — malformed + * JSON, wrong format, unreadable attempts — leave the current session untouched and + * surface as restoreError. + */ + async importSession(recordText: string): Promise { + await this.restoreFromText(recordText); + } + + /** + * The current session as redacted JSON, or null with no session to export. The + * payload structurally has no credential field: the token is excluded by + * construction, as are headers (the client never records them). + */ + exportSession(): string | null { + if (this.intake === null || this.session === null) { + return null; + } + const payload = buildSessionPayload( + this.sceneSummary?.fileName ?? "", + this.intake.text, + this.session, + ); + return JSON.stringify(payload, null, 2); + } + + /** Clears the saved record and its notice; the live session is untouched. */ + discardSavedSession(): void { + if (this.storage !== null) { + try { + this.storage.removeItem(SESSION_STORAGE_KEY); + } catch (error) { + this.storageWarning = `The saved session could not be discarded: ${(error as Error).message}`; + } + } + this.savedSession = null; + this.publish(); + } + + /** Shared reopen/import pipeline: validate, re-intake the scene, rehydrate the record. */ + private async restoreFromText(raw: string): Promise { + const parsed = parseSessionPayload(raw); + if (!parsed.ok) { + this.restoreError = { + code: parsed.failure.code, + message: parsed.failure.message, + path: null, + }; + this.publish(); + return; + } + // An in-flight attempt belongs to the session being replaced: abort it, so its + // late completion cannot argue with the restored record. + this.inFlight?.abort(); + this.inFlight = null; + let intake: SceneIntake; + try { + intake = await intakeSceneText(parsed.payload.scene.text); + } catch (error) { + this.restoreError = + error instanceof SceneIntakeError + ? { code: error.code, message: error.message, path: error.path } + : { code: "unreadable_file", message: (error as Error).message, path: null }; + this.publish(); + return; + } + this.intake = intake; + this.session = rehydrateSession(parsed.payload); + this.sceneSummary = { + fileName: parsed.payload.scene.fileName, + sha256: intake.sha256, + byteLength: intake.bytes.byteLength, + version: intake.version, + warnings: intake.warnings, + }; + this.intakeError = null; + this.restoreError = null; + // The restored record is the live session now; publish re-saves it (with any + // interrupted-attempt normalization) through the ordinary persistence path. + this.savedSession = null; + this.publish(); + } + + /** Reads the saved record (if any) into the summary the page offers for reopening. */ + private loadSavedSummary(): SavedSessionSummary | null { + if (this.storage === null) { + return null; + } + let raw: string | null; + try { + raw = this.storage.getItem(SESSION_STORAGE_KEY); + } catch (error) { + this.storageWarning = `The saved session could not be read: ${(error as Error).message}`; + return null; + } + if (raw === null) { + return null; + } + const parsed = parseSessionPayload(raw); + if (!parsed.ok) { + // Keep the stored bytes (they may be recoverable by hand); only the offer goes away. + this.storageWarning = `A saved session record could not be read (${parsed.failure.message}); reopening stays unavailable and the saved bytes are kept.`; + return null; + } + return savedSessionSummaryOf(parsed.payload); + } + + /** + * The write side of browser-local persistence: while a session is open, every + * published state re-saves the redacted record. Starting a new session replaces the + * saved one (one slot, like the page's one session at a time). The payload carries + * no credential field, so no caller can put the token in storage through here. + */ + private saveToStorage(): void { + if (this.storage === null || this.intake === null || this.session === null) { + return; + } + try { + const payload = buildSessionPayload( + this.sceneSummary?.fileName ?? "", + this.intake.text, + this.session, + ); + this.storage.setItem(SESSION_STORAGE_KEY, JSON.stringify(payload)); + this.storageWarning = null; + } catch (error) { + this.storageWarning = `The session could not be saved in this browser: ${(error as Error).message}`; + } + } + private publish(): void { + this.saveToStorage(); this.current = { baseUrl: this.baseUrl, hasToken: this.token !== "", @@ -347,6 +547,9 @@ export class SessionStore { intakeError: this.intakeError, session: this.session, display: displayedView(this.session), + savedSession: this.savedSession, + restoreError: this.restoreError, + storageWarning: this.storageWarning, }; for (const listener of this.listeners) { listener(); From 789fa35836f1acfb026f3b38c31d36dbaafea4a7 Mon Sep 17 00:00:00 2001 From: Sam Gu <127461594+SamGu-NRX@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:08:48 +0000 Subject: [PATCH 4/4] M4: real-browser evidence, claim ledger, and client fixes Playwright journeys drive the production build end to end against the contract fake served over loopback HTTP only (127.0.0.1); the shared helper aborts and asserts the absence of any non-loopback request. Fifteen journeys cover bound submission with the upload hash, cancel and retry, the 422 refusal quoting the server's message, the mismatch witness, inspector history, reopen with intact history and no token, export/import into a fresh context, 390x844 with no overflow, keyboard-only operation, and reduced motion. Axe scans of the empty, bound, refusal and inspector states in light and dark emulation record zero violations of any impact level in all eight scans (evidence/axe/*.json), with screenshots under evidence/screenshots/. Client fixes the journeys surfaced: reopening a session (or importing a record) now restores the placement server origin from the newest request receipt instead of falling back to the built-in default, a minimal stylesheet lets long hashes wrap instead of overflowing a 390px viewport, and the axe inspector scan resets fake behavior before its submission. CLAIMS.md walks every user-visible claim to the code and the evidence that proves it, including the claims deliberately not made (no safe-placement guarantee, capture stays in the iOS app). 123/123 vitest, biome, tsc, production build, 15/15 Playwright. Co-authored-by: obvious-autobuild[bot] <262744130+obvious-autobuild[bot]@users.noreply.github.com> --- web/.gitignore | 2 + web/CLAIMS.md | 186 + web/README.md | 13 + web/biome.json | 3 +- web/evidence/axe/bound-dark.json | 8523 ++++++++++++++++ web/evidence/axe/bound-light.json | 8523 ++++++++++++++++ web/evidence/axe/empty-dark.json | 4353 ++++++++ web/evidence/axe/empty-light.json | 4353 ++++++++ web/evidence/axe/inspector-dark.json | 8994 +++++++++++++++++ web/evidence/axe/inspector-light.json | 8994 +++++++++++++++++ web/evidence/axe/refusal-dark.json | 5467 ++++++++++ web/evidence/axe/refusal-light.json | 5467 ++++++++++ web/evidence/loopback-server.mjs | 142 + web/evidence/screenshots/01-empty-desktop.png | Bin 0 -> 69634 bytes web/evidence/screenshots/02-bound-result.png | Bin 0 -> 250255 bytes .../screenshots/03-cancelled-midflight.png | Bin 0 -> 115978 bytes web/evidence/screenshots/04-refusal-422.png | Bin 0 -> 134273 bytes .../screenshots/05-mismatch-witness.png | Bin 0 -> 151285 bytes .../screenshots/06-inspector-open.png | Bin 0 -> 282216 bytes web/evidence/screenshots/07-reopen-offer.png | Bin 0 -> 77436 bytes .../screenshots/08-reopened-history.png | Bin 0 -> 250265 bytes .../screenshots/09-imported-session.png | Bin 0 -> 282224 bytes .../screenshots/10-mobile-390x844.png | Bin 0 -> 239415 bytes .../screenshots/11-keyboard-run-focus.png | Bin 0 -> 250265 bytes .../screenshots/12-reduced-motion-bound.png | Bin 0 -> 250255 bytes web/evidence/screenshots/13-axe-empty.png | Bin 0 -> 69634 bytes web/evidence/screenshots/14-axe-bound.png | Bin 0 -> 250255 bytes web/evidence/screenshots/15-axe-refusal.png | Bin 0 -> 134273 bytes web/evidence/screenshots/16-axe-inspector.png | Bin 0 -> 282216 bytes .../screenshots/17-dark-mode-bound.png | Bin 0 -> 250255 bytes web/package.json | 3 + web/playwright.config.ts | 50 + web/pnpm-lock.yaml | 47 + web/src/App.tsx | 1 + web/src/lib/session/persist.test.ts | 20 + web/src/lib/session/store.ts | 28 + web/src/styles/app.css | 10 + web/tests/axe-states.spec.ts | 89 + web/tests/helpers.ts | 130 + web/tests/session-journey.spec.ts | 305 + web/tsconfig.node.json | 4 +- 41 files changed, 55704 insertions(+), 3 deletions(-) create mode 100644 web/CLAIMS.md create mode 100644 web/evidence/axe/bound-dark.json create mode 100644 web/evidence/axe/bound-light.json create mode 100644 web/evidence/axe/empty-dark.json create mode 100644 web/evidence/axe/empty-light.json create mode 100644 web/evidence/axe/inspector-dark.json create mode 100644 web/evidence/axe/inspector-light.json create mode 100644 web/evidence/axe/refusal-dark.json create mode 100644 web/evidence/axe/refusal-light.json create mode 100644 web/evidence/loopback-server.mjs create mode 100644 web/evidence/screenshots/01-empty-desktop.png create mode 100644 web/evidence/screenshots/02-bound-result.png create mode 100644 web/evidence/screenshots/03-cancelled-midflight.png create mode 100644 web/evidence/screenshots/04-refusal-422.png create mode 100644 web/evidence/screenshots/05-mismatch-witness.png create mode 100644 web/evidence/screenshots/06-inspector-open.png create mode 100644 web/evidence/screenshots/07-reopen-offer.png create mode 100644 web/evidence/screenshots/08-reopened-history.png create mode 100644 web/evidence/screenshots/09-imported-session.png create mode 100644 web/evidence/screenshots/10-mobile-390x844.png create mode 100644 web/evidence/screenshots/11-keyboard-run-focus.png create mode 100644 web/evidence/screenshots/12-reduced-motion-bound.png create mode 100644 web/evidence/screenshots/13-axe-empty.png create mode 100644 web/evidence/screenshots/14-axe-bound.png create mode 100644 web/evidence/screenshots/15-axe-refusal.png create mode 100644 web/evidence/screenshots/16-axe-inspector.png create mode 100644 web/evidence/screenshots/17-dark-mode-bound.png create mode 100644 web/playwright.config.ts create mode 100644 web/src/styles/app.css create mode 100644 web/tests/axe-states.spec.ts create mode 100644 web/tests/helpers.ts create mode 100644 web/tests/session-journey.spec.ts diff --git a/web/.gitignore b/web/.gitignore index 6b72902c8..ba613f3b9 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -3,3 +3,5 @@ dist/ coverage/ *.tsbuildinfo *.local +test-results/ +playwright-report/ diff --git a/web/CLAIMS.md b/web/CLAIMS.md new file mode 100644 index 000000000..28b7f5e81 --- /dev/null +++ b/web/CLAIMS.md @@ -0,0 +1,186 @@ +# Claim ledger + +Every user-visible claim this app and its README make about the placement session, +mapped to the code that implements it and the test or committed evidence that proves +it. Claims the app deliberately does not make are at the end — they are part of the +contract too. + +How to reproduce the evidence: `pnpm install && pnpm test` (unit) and +`pnpm test:e2e` (browser). Both run fully offline against the in-process contract +fake (`src/lib/contract/contract-fake.ts`) served by the loopback adapter +(`evidence/loopback-server.mjs`, bound to `127.0.0.1` only); no test or evidence run +touches a real network endpoint — `tests/helpers.ts` aborts every non-loopback +request and asserts the block list stayed empty. + +## Claims the app makes + +### 1. A result is bound to the upload's SHA-256 + +- **Said where:** README intro; the app shows "Bound to upload ``" under the + result and in the inspector (e.g. `28f11aae87d8…`). +- **Code:** the server returns the binding; the client displays it verbatim from the + result (`src/components/OutcomePanel.tsx` "Bound to upload" + `result.stats.input_sha256`; + same in `src/components/Inspector.tsx`), and the upload bytes are hashed server-side + per the contract (`src/lib/contract/`). +- **Proves it:** + - Unit: contract tests assert `input_sha256` is the SHA-256 of the scene text sent + (`src/lib/contract/*.test.ts`). + - Browser: `tests/session-journey.spec.ts` "picks a scene, submits…" computes the + scene text's SHA-256 in the test and asserts that exact hash is visible in a + `code` element; the export/import journey re-checks the same hash after a + round-trip through a file. + - Screenshot: `evidence/screenshots/02-bound-result.png`. + +### 2. The bearer token is never persisted, exported, or logged + +- **Said where:** README ("stays in memory for the next submission only — never + persisted and never logged"); the app's own notice under the token field ("A token + is set. It stays in this page's memory only: never stored, never logged…"). +- **Code:** `src/components/ServerFields.tsx` keeps the token in an uncontrolled + input wired only to the store's in-memory field; `src/lib/session/persist.ts` + serializes the session record from receipts and outcomes — the token is not part + of the record shape, so no persistence or export path can include it. +- **Proves it:** + - Unit: `src/lib/session/persist.test.ts` round-trips the record and asserts the + token never appears; the reopen tests assert no token is restored. + - Browser: the reopen journey fills a synthetic token, then reads + `localStorage` and asserts the stored record contains neither the token nor the + word "authorization" (`tests/session-journey.spec.ts` "reopens the persisted + session…"); the export journey downloads the export file and makes the same two + assertions on the raw file bytes. + - The export test then imports that file into a brand-new browser context: the + token notice is back to "Optional…", i.e. nothing was restored. + +### 3. An export (and the saved record) contains exactly the documented redacted shape + +- **Said where:** README section "Saved sessions: reopen, export, import" — + `format`/`version`/`savedAt`, `scene` with the file's exact text, `session` with + attempt history, receipts (URL, method, content types, byte lengths, request and + body hashes, capped excerpt, truncation flag, timing), outcomes, witnesses; and + the excludes list (token, HTTP headers, body beyond the capped excerpt, anything + outside the one record). +- **Code:** `src/lib/session/persist.ts` (record shape and redaction), export button + in `src/components/SessionPanel.tsx`. +- **Proves it:** + - Unit: `src/lib/session/persist.test.ts` asserts the record and export shape, + byte-length/hash fields, the capped excerpt, and absence of headers and token. + - Browser: the export journey parses the downloaded JSON and asserts `format`, + `version`, `savedAt`, `scene.fileName`, `scene.text` equal to the synthetic + scene text, two attempts, and no token or "authorization" anywhere in the file + (`tests/session-journey.spec.ts` "exports the redacted record…"). + - Screenshot: `evidence/screenshots/08-export-download.png`. + +### 4. History is never displayed as the current answer + +- **Said where:** the inspector's own copy — history entries say "Its result stays + in history and is not displayed as the answer", mismatched results are "kept as a + witness, never displayed", and stale attempts get ". Not the current answer.". +- **Code:** `src/components/Inspector.tsx` renders only the newest attempt's bound + result as the answer (labelled "The result's own decision:", never bare + "Decision:" — that heading is reserved for the current outcome panel); stale, + mismatched, unreadable and late attempts are rendered as history and witnesses. + The store's `display` state picks the current outcome; history entries cannot + promote themselves (`src/lib/session/store.ts`). +- **Proves it:** + - Unit: `src/lib/session/store.test.ts` (re-verification on restore, newest + attempt selection) and `src/lib/session/persist.test.ts` (interrupted in-flight + attempt reopens as cancelled, never as a settled answer). + - Browser: the mismatch journey asserts the word "Decision:" appears **zero** + times after a mismatched answer is recorded (`getByText("Decision:").toHaveCount(0)`) + while the witness text "the request sent … the answer claims …" is visible; the + inspector journey asserts the newest bound result is labelled current and the + earlier attempt shows as history. + - Screenshot: `evidence/screenshots/06-inspector-open.png`. + +### 5. Result claims are quoted as the server's own verdict + +- **Said where:** the outcome panel prints `Decision: `, the + inspector labels its copy "The result's own decision:", and refusals show "The + server said (, HTTP ): ". +- **Code:** `src/components/OutcomePanel.tsx` (decision, refusal quote), + `src/components/Inspector.tsx` ("The result's own decision:"), the refusal + detail with the server's envelope message and JSON pointer + (`src/components/OutcomePanel.tsx`, fed by the contract's 422 envelope). +- **Proves it:** + - Unit: contract tests pin the 422 envelope shape the UI quotes + (`src/lib/contract/*.test.ts`). + - Browser: the refusal journey asserts the server's message text + ("invalid_scene, HTTP 422") is quoted on the page + (`tests/session-journey.spec.ts` "sees the refusal…"), and the mismatch journey + keeps "Decision:" off the page while a witness records what the server *claimed*. + - Screenshots: `evidence/screenshots/04-refusal-422.png`, `05-mismatch-witness.png`. + +### 6. A closed tab does not end the session — and reopening restores the server too + +- **Said where:** README "Closing the tab does not end the session: the next page + load offers to reopen it"; interrupted in-flight attempts reopen as `cancelled`. +- **Code:** `src/lib/session/persist.ts` (versioned browser-local record under one + key, `basescanning-web.session.v1`), `src/lib/session/store.ts` (reopen re-intakes + the saved scene text through the same intake as a fresh upload, re-verifies the + newest bound result, marks a mid-flight attempt cancelled, and — since this + branch — restores the placement server origin from the newest request receipt so + the next attempt goes to the server the session actually talked to). +- **Proves it:** + - Unit: `src/lib/session/persist.test.ts` — reopen with two attempts intact + (history length 2), interrupted attempt becomes cancelled, corrupt record is + reported with bytes kept, and the record's own server origin is adopted on + reopen (while the request path never leaks into the base URL). + - Browser: the reopen journey fills the token, submits twice (cancel + retry + history), reloads the page, reopens, and asserts history, no token, and that a + fresh submission lands on the loopback server (it would `ERR_CONNECTION_REFUSED` + against any other origin); the export/import journey proves the same record + restores standalone in a brand-new context. + - Screenshot: `evidence/screenshots/07-reopened-session.png`. + +### 7. The journey is operable without a mouse, has no reducible motion, and fits a phone + +- **Said where:** this ledger and the PR description (the README makes no motion + claim — see "not made"). +- **Proves it:** + - Keyboard: `tests/session-journey.spec.ts` "every control is operable from the + keyboard alone" walks the full tab order (`server-base-url` → `server-token` → + `scene-file` → Export → import → Run placement — the exact DOM order) and runs + the whole submission with only Tab and Enter. + - Reduced motion: the reduced-motion test scans every stylesheet rule for + `animation` and `transition` properties; the app ships none (the single rule in + `src/styles/app.css` is `overflow-wrap` for long hashes). The status line's + "in flight → bound" progression is text, not animation. + - Responsive: the 390×844 journey asserts no horizontal overflow at any point of + the full flow; screenshot `evidence/screenshots/10-mobile-390x844.png`. + - Accessibility: axe scans of empty, bound, refusal and inspector states in light + and dark emulation recorded in `evidence/axe/*.json` — **zero violations of any + impact level in all eight scans** (verified by `tests/axe-states.spec.ts`, which + fails the suite on any violation). + +## Claims the app deliberately does NOT make + +### No safe-placement guarantee + +The app reports the server's verdict and the evidence trail; it never claims a +placement is safe, correct or allowed. Words like "safe", "pass is correct", or +"verified" do not appear over results — the strongest statement the UI makes is +what the server said, bound to which bytes. The mismatch journey pins this: a +server answer that names different bytes is recorded as a witness, and the page +still shows no decision. + +### Capture stays in the iOS app + +The web client is a zero-install *experiment* surface: pick a scene file you +already have, aim it at a placement server, read the answer. There is no camera, +photo picker, or capture flow here — the README says so and the scene input only +accepts a `.json` file. Any claim about capturing homes belongs to the BaseScanning +iOS app, not to this client. + +### No motion/animation claim in the README + +The README does not advertise reduced-motion support because there is nothing to +reduce: the app ships no animations or transitions (verified by the stylesheet +scan above). If animation is ever added, this becomes a claim that needs its own +tests. + +### No network privacy claim beyond redaction + +The client claims receipts record sizes, hashes and timings — not headers, not +bodies beyond the capped excerpt. It does not claim the placement server is +trustworthy, that HTTPS is enforced for you, or that the loopback evidence setup +models real-network behavior beyond the contract. diff --git a/web/README.md b/web/README.md index f9a78cc06..271a2095f 100644 --- a/web/README.md +++ b/web/README.md @@ -70,6 +70,19 @@ Because the scene text travels in full, treat an export file as you treat the original `scene.json`: it is a capture record, and the repository's rule about synthetic data applies to it too. +## Browser evidence + +`pnpm test:e2e` drives the real production build through the whole session journey — +pick a scene, submit, cancel, retry, refusal, mismatch witness, inspector history, +reopen, export/import, 390×844, keyboard-only, reduced motion — plus axe scans of the +empty, bound, refusal and inspector states in light and dark emulation. Everything +runs against the contract fake served over loopback HTTP (`evidence/loopback-server.mjs`, +bound to `127.0.0.1` only); no test or evidence run touches a real network endpoint, +and the shared helper aborts and asserts the absence of any non-loopback request. +Committed evidence lives in `evidence/screenshots/` and `evidence/axe/` (the JSON +records assert zero violations of any impact level in all eight scans), and +`CLAIMS.md` maps every user-visible claim to the code and evidence that proves it. + ## Conventions - Lengths are stored in meters. Convert to feet and inches only for display, using `src/lib/units.ts`. diff --git a/web/biome.json b/web/biome.json index 71d70060e..7d40276f7 100644 --- a/web/biome.json +++ b/web/biome.json @@ -6,7 +6,8 @@ "useIgnoreFile": true }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "includes": ["**", "!evidence/axe"] }, "formatter": { "enabled": true, diff --git a/web/evidence/axe/bound-dark.json b/web/evidence/axe/bound-dark.json new file mode 100644 index 000000000..2e4af8129 --- /dev/null +++ b/web/evidence/axe/bound-dark.json @@ -0,0 +1,8523 @@ +{ + "state": "bound", + "colorScheme": "dark", + "testEngine": { + "name": "axe-core", + "version": "4.13.0" + }, + "testRunner": { + "name": "axe" + }, + "testEnvironment": { + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/156.0.8078.4 Safari/537.36", + "windowWidth": 1280, + "windowHeight": 800, + "orientationAngle": 0, + "orientationType": "landscape-primary" + }, + "timestamp": "2026-10-11T03:05:30.176Z", + "url": "http://127.0.0.1:4301/", + "toolOptions": { + "reporter": "v1" + }, + "inapplicable": [ + { + "id": "accesskeys", + "impact": null, + "tags": [ + "cat.keyboard", + "best-practice" + ], + "description": "Ensure every accesskey attribute value is unique", + "help": "accesskey attribute value should be unique", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/accesskeys?application=playwright", + "nodes": [] + }, + { + "id": "area-alt", + "impact": null, + "tags": [ + "cat.text-alternatives", + "wcag2a", + "wcag244", + "wcag412", + "section508", + "section508.22.a", + "TTv5", + "TT6.a", + "EN-301-549", + "EN-9.2.4.4", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-1.1.2" + ], + "description": "Ensure elements of image maps have alternative text", + "help": "Active elements must have alternative text", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/area-alt?application=playwright", + "nodes": [] + }, + { + "id": "aria-braille-equivalent", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "EN-301-549", + "EN-9.4.1.2" + ], + "description": "Ensure aria-braillelabel and aria-brailleroledescription have a non-braille equivalent", + "help": "aria-braille attributes must have a non-braille equivalent", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-braille-equivalent?application=playwright", + "nodes": [] + }, + { + "id": "aria-command-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT6.a", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-11.9.1" + ], + "description": "Ensure every ARIA button, link and menuitem has an accessible name", + "help": "ARIA commands must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-command-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-dialog-name", + "impact": null, + "tags": [ + "cat.aria", + "best-practice" + ], + "description": "Ensure every ARIA dialog and alertdialog node has an accessible name", + "help": "ARIA dialog and alertdialog nodes should have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-dialog-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-hidden-focus", + "impact": null, + "tags": [ + "cat.name-role-value", + "wcag2a", + "wcag412", + "TTv5", + "TT6.a", + "EN-301-549", + "EN-9.4.1.2", + "RGAAv4", + "RGAA-10.8.1" + ], + "description": "Ensure aria-hidden elements are not focusable nor contain focusable elements", + "help": "ARIA hidden element must not be focusable or contain focusable elements", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-hidden-focus?application=playwright", + "nodes": [] + }, + { + "id": "aria-input-field-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT5.c", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-11.1.1" + ], + "description": "Ensure every ARIA input field has an accessible name", + "help": "ARIA input fields must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-input-field-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-meter-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag111", + "EN-301-549", + "EN-9.1.1.1", + "RGAAv4", + "RGAA-11.1.1" + ], + "description": "Ensure every ARIA meter node has an accessible name", + "help": "ARIA meter nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-meter-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-progressbar-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag111", + "EN-301-549", + "EN-9.1.1.1", + "RGAAv4", + "RGAA-11.1.1" + ], + "description": "Ensure every ARIA progressbar node has an accessible name", + "help": "ARIA progressbar nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-progressbar-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-required-children", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag131", + "EN-301-549", + "EN-9.1.3.1", + "RGAAv4", + "RGAA-9.3.1" + ], + "description": "Ensure elements with an ARIA role that require child roles contain them", + "help": "Certain ARIA roles must contain particular children", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-required-children?application=playwright", + "nodes": [] + }, + { + "id": "aria-required-parent", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag131", + "EN-301-549", + "EN-9.1.3.1", + "RGAAv4", + "RGAA-9.3.1" + ], + "description": "Ensure elements with an ARIA role that require parent roles are contained by them", + "help": "Certain ARIA roles must be contained by particular parents", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-required-parent?application=playwright", + "nodes": [] + }, + { + "id": "aria-tab-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT5.c", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-7.1.1" + ], + "description": "Ensure every ARIA tab node has an accessible name", + "help": "ARIA tab nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-tab-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-text", + "impact": null, + "tags": [ + "cat.aria", + "best-practice" + ], + "description": "Ensure role=\"text\" is used on elements with no focusable descendants", + "help": "\"role=text\" should have no focusable descendants", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-text?application=playwright", + "nodes": [] + }, + { + "id": "aria-toggle-field-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "TTv5", + "TT5.c", + "EN-301-549", + "EN-9.4.1.2", + "ACT", + "RGAAv4", + "RGAA-7.1.1" + ], + "description": "Ensure every ARIA toggle field has an accessible name", + "help": "ARIA toggle fields must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-toggle-field-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-tooltip-name", + "impact": null, + "tags": [ + "cat.aria", + "wcag2a", + "wcag412", + "EN-301-549", + "EN-9.4.1.2" + ], + "description": "Ensure every ARIA tooltip node has an accessible name", + "help": "ARIA tooltip nodes must have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-tooltip-name?application=playwright", + "nodes": [] + }, + { + "id": "aria-treeitem-name", + "impact": null, + "tags": [ + "cat.aria", + "best-practice" + ], + "description": "Ensure every ARIA treeitem node has an accessible name", + "help": "ARIA treeitem nodes should have an accessible name", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/aria-treeitem-name?application=playwright", + "nodes": [] + }, + { + "id": "blink", + "impact": null, + "tags": [ + "cat.time-and-media", + "wcag2a", + "wcag222", + "section508", + "section508.22.j", + "TTv5", + "TT2.b", + "EN-301-549", + "EN-9.2.2.2", + "RGAAv4", + "RGAA-13.8.1" + ], + "description": "Ensure elements are not used", + "help": " elements are deprecated and must not be used", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/blink?application=playwright", + "nodes": [] + }, + { + "id": "bypass", + "impact": null, + "tags": [ + "cat.keyboard", + "wcag2a", + "wcag241", + "section508", + "section508.22.o", + "TTv5", + "TT9.a", + "EN-301-549", + "EN-9.2.4.1", + "RGAAv4", + "RGAA-12.7.1" + ], + "description": "Ensure each page has at least one mechanism for a user to bypass navigation and jump straight to the content", + "help": "Page must have means to bypass repeated blocks", + "helpUrl": "https://dequeuniversity.com/rules/axe/4.13/bypass?application=playwright", + "nodes": [] + }, + { + "id": "definition-list", + "impact": null, + "tags": [ + "cat.structure", + "wcag2a", + "wcag131", + "EN-301-549", + "EN-9.1.3.1", + "RGAAv4", + "RGAA-9.3.3" + ], + "description": "Ensure
    elements are structured correctly", + "help": "
    elements must only directly contain properly-ordered
    and
    groups,