From 859cb649c84d5ff8c9924dddd415e681b25f0117 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Fri, 2 Oct 2026 22:03:51 -0500 Subject: [PATCH] Stage 9 close-out: user-initiated full re-verify of active slot (S20.3 cadence) wired into Check my data, boot light-check budget test --- src/app/main.ts | 72 ++++++++--- src/sync/reverify.ts | 149 ++++++++++++++++++++++ src/ui/views/status/status.ts | 14 ++- tests/unit/reverify.test.ts | 229 ++++++++++++++++++++++++++++++++++ tests/unit/status.test.ts | 13 ++ 5 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 src/sync/reverify.ts create mode 100644 tests/unit/reverify.test.ts diff --git a/src/app/main.ts b/src/app/main.ts index 2ac7271..5420455 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -23,6 +23,7 @@ import { openUserDB, addFavorite, removeFavorite, putPrefs } from "../data/user/ import { createScheduleView } from "../ui/views/schedule/schedule.js"; import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedule/schedule.js"; import { restorePreviousSlot } from "../sync/activation.js"; +import { fullReverifyActiveSlot } from "../sync/reverify.js"; import { runSync } from "./sync.js"; import { createLayout, setActiveNav } from "./layout.js"; import { Router, routeForPath, normalizePath } from "../ui/router/router.js"; @@ -109,6 +110,57 @@ function mount(): { router: Router; cleanup: () => void } { statusChip.setAttribute("aria-label", `${chip.label} Activate for status details.`); } + // §20.3 cadence: "Check my data" is the user-initiated FULL re-verify + // (all hashes + schema); the plain re-evaluation path stays light. + let checkBusy = false; + let checkMessage: string | null = null; + + function runCheck(report: BootReadinessReport, dialog: HTMLDialogElement): void { + if (checkBusy) return; + checkBusy = true; + checkMessage = "Checking all festival data on this device…"; + dialog.replaceChildren(statusViewFor(report)); + void fullReverifyActiveSlot() + .then((outcome) => { + checkBusy = false; + checkMessage = outcome.ok + ? `All festival data checked — v${String(outcome.packageVersion)} verified on this device.` + : outcome.quarantined + ? `Check found a problem (${outcome.reason}). This version was quarantined; reopening status re-checks what remains.` + : `Check found no active dataset (${outcome.reason}).`; + }) + .catch(() => { + checkBusy = false; + checkMessage = "Check could not complete on this device."; + }) + .then(() => void refreshReadiness(true)); + } + + function statusViewFor(report: BootReadinessReport): HTMLElement { + return createStatusView( + report, + { + onCheckData: () => { + const dialog = statusDialog; + const current = latestReport ?? report; + if (dialog) runCheck(current, dialog); + }, + onGetData: () => { + showPrepGuidance(); + }, + onRestore: report.canRestore + ? () => { + void restorePreviousSlot() + .catch(() => false) + .then(() => refreshReadiness(true)); + } + : null, + onClose: () => statusDialog?.close(), + }, + checkMessage, + ); + } + function showStatus(report: BootReadinessReport): void { if (!statusDialog) { statusDialog = document.createElement("dialog"); @@ -121,25 +173,7 @@ function mount(): { router: Router; cleanup: () => void } { root.append(statusDialog); } const dialog = statusDialog; - const close = (): void => { - dialog.close(); - }; - dialog.replaceChildren( - createStatusView(report, { - onCheckData: () => void refreshReadiness(true), - onGetData: () => { - showPrepGuidance(); - }, - onRestore: report.canRestore - ? () => { - void restorePreviousSlot() - .catch(() => false) - .then(() => refreshReadiness(true)); - } - : null, - onClose: close, - }), - ); + dialog.replaceChildren(statusViewFor(report)); if (!dialog.open) dialog.showModal(); } diff --git a/src/sync/reverify.ts b/src/sync/reverify.ts new file mode 100644 index 0000000..4682a33 --- /dev/null +++ b/src/sync/reverify.ts @@ -0,0 +1,149 @@ +/** + * Full re-verification of the ACTIVE slot (§20.3 cadence). + * + * The boot light check never hashes; this is the heavy pass the cadence table + * schedules for user-initiated "Check my data" and post-IDB-error checks: + * every required section is re-serialized (canonical bytes) and re-hashed + * against the manifest record, every asset blob is re-hashed, and the schema + * + compatibility gates run again. Any failure quarantines the checked + * edition/version (§20.3 "quarantine on failure", SPIKE-02 F-5) — the active + * dataset itself is never modified here; state transitions belong to boot. + * + * Trace: IMPLEMENTATION-CONTRACT.md §20.3, SPIKE-05 §4, SPIKE-02 F-5. + */ +import { canonicalJson } from "../../pipeline/canonical-json.js"; +import { + openSlotDB, + readSlotAsset, + readSlotFile, + readSlotFileMeta, + readSlotManifest, +} from "../data/slot/store.js"; +import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js"; +import { isCompatible, validateManifest } from "../data/festival-package/validation.js"; +import type { FestivalManifest, SectionId } from "../data/festival-package/types.js"; +import { openUserDB } from "../data/user/store.js"; +import { addQuarantined } from "../data/user/quarantine.js"; +import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../domain/readiness/config.js"; +import { sha256Hex } from "./verifier/hash.js"; +import type { VerifyErrorCode } from "./verifier/types.js"; + +export interface ReverifyDeps { + readonly appVersion?: string; + readonly supportedSchemaRange?: readonly number[]; + readonly now?: () => number; +} + +export type ReverifyOutcome = + | { readonly ok: true; readonly packageVersion: number } + | { readonly ok: false; readonly reason: string; readonly quarantined: boolean }; + +interface ReverifyFailure { + readonly reason: string; + readonly code: VerifyErrorCode; +} + +const SECTION_IDS = ["emergency", "schedule", "map", "info", "assets"] as const; + +function fail(reason: string, code: VerifyErrorCode): ReverifyFailure { + return { reason, code }; +} + +function requiredSectionIds(manifest: FestivalManifest): readonly SectionId[] { + return SECTION_IDS.filter((id) => manifest.sections[id].required !== false); +} + +async function checkSections( + slot: IDBDatabase, + manifest: FestivalManifest, +): Promise { + for (const id of requiredSectionIds(manifest)) { + const entry = manifest.sections[id]; + const stored = await readSlotFileMeta(slot, id); + if (!stored) return fail(`missing ${id}`, "missing_file"); + // Stored meta must agree with the manifest before bytes are trusted. + if (stored.sha256 !== entry.sha256) return fail(`record mismatch ${id}`, "hash_mismatch"); + const json = await readSlotFile(slot, id); + if (json === undefined) return fail(`unreadable ${id}`, "missing_file"); + // Sections persist the parsed canonical object; re-serialization through + // the deterministic canonicalizer reproduces the published bytes exactly. + const bytes = new TextEncoder().encode(canonicalJson(json)); + if (bytes.length !== entry.bytes) return fail(`size mismatch ${id}`, "size_mismatch"); + const hex = await sha256Hex(bytes); + if (hex !== entry.sha256) return fail(`hash mismatch ${id}`, "hash_mismatch"); + } + return null; +} + +async function checkAssets(slot: IDBDatabase): Promise { + const inventory = (await readSlotFile<{ assets?: unknown }>(slot, "assets")) as + { assets?: unknown } | undefined; + const list = inventory?.assets; + if (!Array.isArray(list)) return fail("assets inventory unreadable", "malformed_manifest"); + for (const item of list) { + const a = item as Record; + if (typeof a.id !== "string" || typeof a.sha256 !== "string") continue; + if (a.required === false) continue; + const record = await readSlotAsset(slot, a.id); + if (!record) return fail(`missing asset ${a.id}`, "missing_file"); + if (record.sha256 !== a.sha256) return fail(`record mismatch asset ${a.id}`, "hash_mismatch"); + const bytes = new Uint8Array(await record.blob.arrayBuffer()); + if (bytes.length !== record.bytes) return fail(`size mismatch asset ${a.id}`, "size_mismatch"); + const hex = await sha256Hex(bytes); + if (hex !== a.sha256) return fail(`hash mismatch asset ${a.id}`, "hash_mismatch"); + } + return null; +} + +/** + * Full C3–C7 re-verification of the active slot: schema, compatibility, + * per-file hashes (sections + assets). Any failure quarantines the checked + * edition/version in lumen-user; nothing on disk is rewritten. + */ +export async function fullReverifyActiveSlot(deps: ReverifyDeps = {}): Promise { + const appVersion = deps.appVersion ?? APP_VERSION; + const supportedSchemaRange = deps.supportedSchemaRange ?? SUPPORTED_SCHEMA_RANGE; + const now = deps.now ?? ((): number => Date.now()); + + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + system.close(); + if (!meta.activeSlot || !meta.activeEdition || meta.activePackageVersion === null) { + return { ok: false, reason: "no active dataset", quarantined: false }; + } + const edition = meta.activeEdition; + const packageVersion = meta.activePackageVersion; + + let failure: ReverifyFailure | null = null; + const slot = await openSlotDB(meta.activeSlot); + try { + const manifest = await readSlotManifest(slot); + if (!manifest) { + failure = fail("manifest missing from active slot", "missing_file"); + } else { + const schema = validateManifest(manifest); + if (!schema.ok) { + failure = fail(`schema invalid: ${schema.reason}`, "malformed_manifest"); + } else if (!isCompatible(manifest, supportedSchemaRange, appVersion)) { + failure = fail("package incompatible with this app", "incompatible_app"); + } + failure ??= await checkSections(slot, manifest); + failure ??= await checkAssets(slot); + } + } finally { + slot.close(); + } + + if (!failure) return { ok: true, packageVersion }; + + const user = await openUserDB(); + await addQuarantined(user, { + edition, + packageVersion, + code: failure.code, + reason: `full re-verify: ${failure.reason}`, + at: now(), + }); + user.close(); + return { ok: false, reason: failure.reason, quarantined: true }; +} diff --git a/src/ui/views/status/status.ts b/src/ui/views/status/status.ts index c72ffd1..4fee8dd 100644 --- a/src/ui/views/status/status.ts +++ b/src/ui/views/status/status.ts @@ -62,7 +62,12 @@ function sectionRow(section: SectionStatus): HTMLLIElement { * dialog/overlay lifecycle; this returns fresh content on every call so the * caller can re-render after re-evaluation. */ -export function createStatusView(report: BootReadinessReport, actions: StatusActions): HTMLElement { +export function createStatusView( + report: BootReadinessReport, + actions: StatusActions, + /** Live notice from an in-flight or finished check (transient, not part of the report). */ + checkMessage?: string | null, +): HTMLElement { const section = document.createElement("section"); section.className = "status-view"; section.setAttribute("aria-labelledby", "status-heading"); @@ -118,6 +123,13 @@ export function createStatusView(report: BootReadinessReport, actions: StatusAct : `Storage: ${formatBytes(usage.usage)} used of ${formatBytes(usage.quota)}${report.shellCache ? ` · shell ${report.shellCache}` : ""}`; section.append(text("p", storageText)); + if (checkMessage) { + const notice = text("p", checkMessage); + notice.className = "status-check-notice"; + notice.setAttribute("role", "status"); + section.append(notice); + } + const buttons = document.createElement("div"); buttons.className = "status-actions"; diff --git a/tests/unit/reverify.test.ts b/tests/unit/reverify.test.ts new file mode 100644 index 0000000..b1d64f6 --- /dev/null +++ b/tests/unit/reverify.test.ts @@ -0,0 +1,229 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/require-await */ +/** + * Stage 9 cadence — user-initiated FULL re-verification of the active slot + * (§20.3): clean pass, per-file hash corruption detected + quarantined, + * record tampering detected, asset blob corruption detected, no active + * dataset handled without quarantine. Plus the boot light-check budget + * (≤150 ms target, no hashing — timing measured generously on CI). + * Trace: IMPLEMENTATION-CONTRACT.md §20.3, SPIKE-05 §4, SPIKE-02 F-5. + */ +import { describe, it, expect, beforeEach } from "vitest"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBFactory from "fake-indexeddb/lib/FDBFactory"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange"; + +const g = globalThis as unknown as Record; +g.indexedDB = new FDBFactory() as unknown; +g.IDBKeyRange = FDBKeyRange as unknown; + +import { buildPackage } from "../../pipeline/package.js"; +import { generateTestKeyPair } from "../../pipeline/sign.js"; +import { makeValidInput } from "../../pipeline/fixtures.js"; +import { canonicalJson } from "../../pipeline/canonical-json.js"; +import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js"; +import { verifyPackage } from "../../src/sync/verifier/package.js"; +import { activateStagedPackage, stageVerifiedPackage } from "../../src/sync/activation.js"; +import { fullReverifyActiveSlot } from "../../src/sync/reverify.js"; +import { openSystemDB, readSystemMeta } from "../../src/data/system-meta/store.js"; +import { + openSlotDB, + readSlotAsset, + readSlotManifest, + writeSlotFile, +} from "../../src/data/slot/store.js"; +import { openUserDB } from "../../src/data/user/store.js"; +import { isQuarantined, listQuarantined } from "../../src/data/user/quarantine.js"; +import { withTx } from "../../src/platform/idb/wrapper.js"; +import { DB, SLOT_ASSETS, SLOT_FILES } from "../../src/platform/idb/names.js"; +import type { SectionId } from "../../src/data/festival-package/types.js"; +import { evaluateBootReadiness, defaultBootDeps } from "../../src/app/readiness.js"; + +function deleteDb(name: string): Promise { + return new Promise((resolve, reject) => { + const req = (g.indexedDB as IDBFactory).deleteDatabase(name); + req.onsuccess = () => { + resolve(); + }; + req.onerror = () => { + reject(req.error ?? new Error("delete database failed")); + }; + req.onblocked = () => { + resolve(); + }; + }); +} + +async function activateFixture(version: number): Promise<{ + manifestSha256: string; + edition: string; + files: Map; +}> { + const keyPair = generateTestKeyPair(); + const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + const files = new Map(); + for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes); + for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent); + const result = await verifyPackage( + { + manifestBytes, + signature: built.pkg.signature, + files: { getFile: (name) => Promise.resolve(files.get(name)) }, + emergencyFloor: built.pkg.emergencyFloor, + }, + { + trustedKeys: new Map([ + [keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)], + ]), + appVersion: "1.0.0", + supportedSchemaRange: [1], + }, + ); + if (!result.ok) throw new Error(result.reason); + const staged = await stageVerifiedPackage(result); + const activated = await activateStagedPackage(result, staged, "1.0.0"); + if (!activated.ok) throw new Error("activation failed"); + return { manifestSha256: result.manifestSha256, edition: result.manifest.edition, files }; +} + +async function activeSlot(): Promise<"A" | "B"> { + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + system.close(); + if (!meta.activeSlot) throw new Error("no active slot"); + return meta.activeSlot; +} + +/** Overwrite the stored section JSON (post-verification corruption). */ +async function corruptSection(id: SectionId, json: unknown): Promise { + const slot = await openSlotDB(await activeSlot()); + const manifest = await readSlotManifest(slot); + const entry = manifest?.sections[id]; + if (!entry) throw new Error("manifest entry missing"); + await writeSlotFile(slot, id, { bytes: entry.bytes, sha256: entry.sha256, json }); + slot.close(); +} + +/** Keep bytes but tamper the stored hash record. */ +async function tamperSectionRecord(id: SectionId): Promise { + const slot = await openSlotDB(await activeSlot()); + await withTx(slot, SLOT_FILES, "readwrite", (tx) => { + tx.objectStore(SLOT_FILES).put({ id, bytes: 1, sha256: "f".repeat(64), json: {} }, id); + }); + slot.close(); +} + +beforeEach(async () => { + await Promise.all(Object.values(DB).map((name) => deleteDb(name))); +}); + +describe("full re-verify active slot (§20.3 user check)", () => { + it("clean activated dataset passes with its version", async () => { + await activateFixture(1); + const outcome = await fullReverifyActiveSlot({ + appVersion: "1.0.0", + supportedSchemaRange: [1], + }); + expect(outcome).toEqual({ ok: true, packageVersion: 1 }); + }); + + it("corrupted section JSON is detected and quarantined (F-5)", async () => { + const { edition } = await activateFixture(1); + await corruptSection("schedule", { section: "schedule", events: [] }); + const outcome = await fullReverifyActiveSlot({ + appVersion: "1.0.0", + supportedSchemaRange: [1], + }); + expect(outcome.ok).toBe(false); + if (!outcome.ok) { + expect(outcome.quarantined).toBe(true); + expect(outcome.reason).toContain("schedule"); + } + const user = await openUserDB(); + expect(await isQuarantined(user, edition, 1)).toBe(true); + const list = await listQuarantined(user, edition); + expect(list[0]?.reason).toContain("full re-verify"); + user.close(); + }); + + it("tampered stored hash record is detected", async () => { + await activateFixture(1); + await tamperSectionRecord("map"); + const outcome = await fullReverifyActiveSlot({ + appVersion: "1.0.0", + supportedSchemaRange: [1], + }); + expect(outcome.ok).toBe(false); + if (!outcome.ok) expect(outcome.reason).toContain("record mismatch map"); + }); + + it("corrupted asset blob is detected", async () => { + await activateFixture(1); + const slot = await openSlotDB(await activeSlot()); + const all = await readSlotAsset(slot, "map-base-overview"); + expect(all).toBeDefined(); + await withTx(slot, SLOT_ASSETS, "readwrite", (tx) => { + tx.objectStore(SLOT_ASSETS).put( + { + id: "map-base-overview", + bytes: all?.bytes, + sha256: all?.sha256, + blob: new Blob(["CORRUPT"]), + }, + "map-base-overview", + ); + }); + slot.close(); + const outcome = await fullReverifyActiveSlot({ + appVersion: "1.0.0", + supportedSchemaRange: [1], + }); + expect(outcome.ok).toBe(false); + if (!outcome.ok) expect(outcome.reason).toContain("map-base-overview"); + }); + + it("no active dataset → not ok, nothing quarantined", async () => { + const outcome = await fullReverifyActiveSlot({ + appVersion: "1.0.0", + supportedSchemaRange: [1], + }); + expect(outcome).toMatchObject({ ok: false, quarantined: false }); + const user = await openUserDB(); + expect(await listQuarantined(user, "lumen-2026")).toHaveLength(0); + user.close(); + }); + + it("incompatible app version fails the compatibility gate", async () => { + await activateFixture(1); + const outcome = await fullReverifyActiveSlot({ + appVersion: "0.0.1", + supportedSchemaRange: [1], + }); + expect(outcome.ok).toBe(false); + if (!outcome.ok) expect(outcome.reason).toContain("incompatible"); + }); +}); + +describe("boot light-check budget (§20.3 ≤150 ms, no hashing)", () => { + it("READY boot evaluation on an activated dataset stays within budget", async () => { + await activateFixture(1); + const deps = { + ...defaultBootDeps, + appVersion: "1.0.0", + checkShell: async () => ({ ok: true, cacheName: "lumen-shell-test" }), + loadFloor: () => ({ ok: true, version: "embedded-1", bytes: 4096 }), + estimate: async () => null, + }; + // Warm the IDB open path, then time a representative boot evaluation. + await evaluateBootReadiness(deps); + const start = performance.now(); + const report = await evaluateBootReadiness(deps); + const elapsedMs = performance.now() - start; + expect(report.state).toBe("READY"); + // fake-indexeddb runs faster than real IDB but Node CI jitter is real — + // the 150 ms target is measured with a generous 4x margin on this seam. + expect(elapsedMs).toBeLessThan(600); + }); +}); diff --git a/tests/unit/status.test.ts b/tests/unit/status.test.ts index 8e39a54..6a5ecdf 100644 --- a/tests/unit/status.test.ts +++ b/tests/unit/status.test.ts @@ -123,6 +123,19 @@ describe("Status view", () => { expect(view.innerHTML).not.toContain("map"); }); + it("check notice renders as live status text when provided", () => { + const view = createStatusView( + report(), + actions(), + "All festival data checked — v1 verified on this device.", + ); + const notice = view.querySelector(".status-check-notice"); + expect(notice?.textContent).toContain("v1 verified"); + expect(notice?.getAttribute("role")).toBe("status"); + const plain = createStatusView(report(), actions()); + expect(plain.querySelector(".status-check-notice")).toBeNull(); + }); + it("prep guidance has Back and Close", () => { const onBack = vi.fn(); const onClose = vi.fn();