diff --git a/pipeline/run.ts b/pipeline/run.ts index 4b3e3f9..d7c5cd3 100644 --- a/pipeline/run.ts +++ b/pipeline/run.ts @@ -11,7 +11,7 @@ * Trace: IMPLEMENTATION-CONTRACT.md Stage 6, SPIKE-04 §3 gate 5, ARCH 18.7. */ import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, dirname } from "node:path"; import { buildPackage } from "./package.js"; import { generateTestKeyPair, fingerprintFromPublicPem } from "./sign.js"; import { sha256Hex } from "./hash.js"; @@ -98,7 +98,7 @@ const input: PipelineInput = { schemaVersion: 1, generatedAt: new Date().toISOString(), festival: { name: "SolarPunk Summit 2026", timezone: FEST_TZ, startUtc, endUtc }, - appCompatibility: { minAppVersion: "1.0.0", maxAppVersion: null }, + appCompatibility: { minAppVersion: arg("min-app-version", "1.0.0"), maxAppVersion: null }, content: { emergency, schedule: { ...schedule, events } as unknown as PipelineInput["content"]["schedule"], @@ -216,6 +216,10 @@ log("sign", `signed with test key ${kp.fingerprint.slice(0, 18)}…`); // ——— 5: upload immutable files + flip latest.json (local dir = origin layout §37) ——— const pkgDir = join(outDir, "editions", edition, "packages", String(version)); +function originEditionDir(packageDirectory: string): string { + // /editions//packages/ → /editions/ + return dirname(dirname(packageDirectory)); +} mkdirSync(join(pkgDir, "assets"), { recursive: true }); for (const [, f] of pkg.files) { writeFileSync(join(pkgDir, f.file), f.canonicalBytes); @@ -231,7 +235,10 @@ writeFileSync(join(pkgDir, "signature.json"), JSON.stringify(pkg.signature, null writeFileSync(join(pkgDir, "publicKey.pem"), kp.publicKeyPem); writeFileSync(join(pkgDir, "emergency-floor.json"), JSON.stringify(pkg.emergencyFloor, null, 2)); // Mutable pointer — last write, so immutable files always exist before flip. +// Root pointer per contract §37 + per-edition pointer consumed by the +// page-side HttpTransport (/editions//latest.json). writeFileSync(join(outDir, "latest.json"), JSON.stringify(pkg.latest, null, 2)); +writeFileSync(join(originEditionDir(pkgDir), "latest.json"), JSON.stringify(pkg.latest, null, 2)); log("upload", `wrote immutable tree under ${pkgDir}/ + latest.json flip`); // ——— 6: smoke — verify what we just uploaded (key known from this run) ——— diff --git a/src/app/main.ts b/src/app/main.ts index c80e177..2ac7271 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 { runSync } from "./sync.js"; import { createLayout, setActiveNav } from "./layout.js"; import { Router, routeForPath, normalizePath } from "../ui/router/router.js"; import { createHomeView } from "../ui/views/home/home.js"; @@ -123,14 +124,11 @@ function mount(): { router: Router; cleanup: () => void } { const close = (): void => { dialog.close(); }; - const back = (): void => { - if (latestReport) showStatus(latestReport); - }; dialog.replaceChildren( createStatusView(report, { onCheckData: () => void refreshReadiness(true), onGetData: () => { - dialog.replaceChildren(createPrepGuidanceView(back, close)); + showPrepGuidance(); }, onRestore: report.canRestore ? () => { @@ -145,6 +143,64 @@ function mount(): { router: Router; cleanup: () => void } { if (!dialog.open) dialog.showModal(); } + let syncBusy = false; + let syncMessage: string | null = null; + + function showPrepGuidance(): void { + if (!statusDialog) return; + const dialog = statusDialog; + const close = (): void => { + dialog.close(); + }; + const back = (): void => { + if (latestReport) showStatus(latestReport); + }; + const paint = (): void => { + dialog.replaceChildren( + createPrepGuidanceView(back, close, { + busy: syncBusy, + message: syncMessage, + onDownload: () => { + if (syncBusy) return; + syncBusy = true; + syncMessage = null; + paint(); + void runSync({ + onPhase: (phase) => { + syncMessage = + phase === "checking" + ? "Checking for the latest release…" + : phase === "downloading" + ? "Downloading and verifying…" + : "Activating…"; + paint(); + }, + }).then((outcome) => { + syncBusy = false; + syncMessage = + outcome.status === "ok" + ? `Festival data v${String(outcome.version)} is live. You can go offline now.` + : outcome.status === "no-update" + ? "You already have the latest festival data." + : outcome.status === "offline" + ? "No sync source reachable right now." + : outcome.status === "rejected" + ? `Update rejected — keeping current data. (${outcome.detail})` + : outcome.status === "not-configured" + ? `No sync source configured. (${outcome.detail})` + : `Sync failed. (${outcome.detail})`; + paint(); + // Refresh the readiness chip behind the dialog either way. + void refreshReadiness(false); + }); + }, + }), + ); + }; + paint(); + if (!dialog.open) dialog.showModal(); + } + async function refreshReadiness(openAfter: boolean): Promise { const report = await evaluateBootReadiness(); latestReport = report; diff --git a/src/app/sync.ts b/src/app/sync.ts new file mode 100644 index 0000000..1917f6d --- /dev/null +++ b/src/app/sync.ts @@ -0,0 +1,180 @@ +/** + * App-layer sync coordinator — the one place that composes transport + + * verifier + staging + activation into a single user-initiated run. + * + * Rules: + * - The verifier remains the sole decider (B-4): nothing reaches staging + * without a VerifyOk witness. + * - Quarantine is persistent (§11 no-loop): rejected versions are skipped + * before any manifest/file fetch until latest.json advances past them. + * - lumen-user is never written except through the quarantine store (B-6). + * - All configuration comes from /sync-config.json served alongside the app + * (staging seam; production pins keys in the shell bundle). + */ +import { HttpTransport } from "../sync/transport/http.js"; +import { TransportError } from "../sync/transport/types.js"; +import { pullCandidate } from "../sync/pull.js"; +import { publicKeyFromDerBase64 } from "../sync/verifier/ed25519.js"; +import type { TrustedKeySet } from "../sync/verifier/types.js"; +import { stageVerifiedPackage, activateStagedPackage } from "../sync/activation.js"; +import { openUserDB } from "../data/user/store.js"; +import { quarantineSink, isQuarantined } from "../data/user/quarantine.js"; +import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js"; +import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../domain/readiness/config.js"; + +export interface SyncConfig { + readonly edition: string; + /** Origin serving /editions/... and /latest.json. Same-origin by default. */ + readonly origin: string; + /** Pinned trust: fingerprint ("sha256:") → SPKI DER base64. */ + readonly trustedKeys: Readonly>; +} + +export type SyncOutcome = + | { readonly status: "ok"; readonly version: number } + | { readonly status: "no-update" } + | { readonly status: "offline" } + | { readonly status: "not-configured"; readonly detail: string } + | { readonly status: "rejected"; readonly detail: string } + | { readonly status: "error"; readonly detail: string }; + +export interface SyncRunDeps { + readonly fetchConfig?: () => Promise; + readonly fetchImpl?: typeof fetch; + readonly appVersion?: string; + readonly supportedSchemaRange?: readonly number[]; + readonly onPhase?: (phase: "checking" | "downloading" | "activating") => void; +} + +function isSyncConfig(value: unknown): value is SyncConfig { + if (typeof value !== "object" || value === null) return false; + const c = value as Record; + return ( + typeof c.edition === "string" && + c.edition.length > 0 && + typeof c.origin === "string" && + typeof c.trustedKeys === "object" && + c.trustedKeys !== null && + Object.values(c.trustedKeys as Record).every((k) => typeof k === "string") + ); +} + +export function defaultConfigUrl(): string { + return "/sync-config.json"; +} + +export async function loadSyncConfig( + fetchImpl: typeof fetch, + url: string = defaultConfigUrl(), +): Promise { + try { + const res = await fetchImpl(url, { cache: "no-store" }); + if (!res.ok) return null; + const value: unknown = await res.json(); + return isSyncConfig(value) ? value : null; + } catch { + return null; + } +} + +function keySet(trusted: Readonly>): TrustedKeySet { + const map = new Map(); + for (const [fingerprint, derB64] of Object.entries(trusted)) { + map.set(fingerprint, publicKeyFromDerBase64(derB64)); + } + return map; +} + +function describeError(error: unknown): string { + if (error instanceof TransportError) return `${error.code}: ${error.message}`; + if (error instanceof Error) return error.message; + return String(error); +} + +/** Version currently active on this device (0 when nothing is activated yet). */ +async function currentActiveVersion(): Promise { + const system = await openSystemDB(); + try { + const meta = await readSystemMeta(system); + return meta.activeSlot ? (meta.activePackageVersion ?? 0) : 0; + } finally { + system.close(); + } +} + +/** One user-initiated sync: check pointer → verify → stage → activate. */ +export async function runSync(deps: SyncRunDeps = {}): Promise { + const fetchImpl = deps.fetchImpl ?? globalThis.fetch; + const appVersion = deps.appVersion ?? APP_VERSION; + const schemaRange = deps.supportedSchemaRange ?? SUPPORTED_SCHEMA_RANGE; + + let config: SyncConfig | null; + try { + config = deps.fetchConfig ? await deps.fetchConfig() : await loadSyncConfig(fetchImpl); + } catch { + return { status: "error", detail: "config load failed" }; + } + if (!config) return { status: "not-configured", detail: "sync-config.json missing or invalid" }; + + let trusted: TrustedKeySet; + try { + trusted = keySet(config.trustedKeys); + } catch { + return { status: "not-configured", detail: "trusted key entry is malformed" }; + } + if (trusted.size === 0) return { status: "not-configured", detail: "no trusted keys pinned" }; + + const transport = new HttpTransport(config.origin, { fetchImpl }); + const user = await openUserDB(); + try { + deps.onPhase?.("checking"); + const outcome = await pullCandidate( + transport, + config.edition, + { + trustedKeys: trusted, + appVersion, + supportedSchemaRange: schemaRange, + quarantine: quarantineSink(user), + }, + { + isQuarantined: (packageVersion) => isQuarantined(user, config.edition, packageVersion), + }, + ); + + if (outcome === null) return { status: "offline" }; + if ("skipped" in outcome) { + return { + status: "rejected", + detail: `version ${String(outcome.pointer.packageVersion)} is quarantined; waiting for a newer release`, + }; + } + if (!outcome.result.ok) { + return { status: "rejected", detail: outcome.result.reason }; + } + const verified = outcome.result; + if ( + outcome.pointer.edition === config.edition && + outcome.pointer.packageVersion <= (await currentActiveVersion()) + ) { + return { status: "no-update" }; + } + + deps.onPhase?.("downloading"); + const staged = await stageVerifiedPackage(verified); + + deps.onPhase?.("activating"); + const activated = await activateStagedPackage(verified, staged, appVersion); + if (!activated.ok) { + return { + status: "error", + detail: activated.reason ?? "activation failed", + }; + } + return { status: "ok", version: verified.manifest.packageVersion }; + } catch (error) { + return { status: "error", detail: describeError(error) }; + } finally { + user.close(); + } +} diff --git a/src/ui/views/status/status.ts b/src/ui/views/status/status.ts index c850da6..c72ffd1 100644 --- a/src/ui/views/status/status.ts +++ b/src/ui/views/status/status.ts @@ -168,7 +168,15 @@ export function createStatusView(report: BootReadinessReport, actions: StatusAct } /** Preparation guidance shown for Get/Continue/Restore — sync flow lands in Stage 15. */ -export function createPrepGuidanceView(onBack: () => void, onClose: () => void): HTMLElement { +export function createPrepGuidanceView( + onBack: () => void, + onClose: () => void, + download: { + readonly onDownload: () => void; + readonly busy: boolean; + readonly message: string | null; + } | null = null, +): HTMLElement { const section = document.createElement("section"); section.className = "status-view"; section.setAttribute("aria-labelledby", "prep-heading"); @@ -179,9 +187,25 @@ export function createPrepGuidanceView(onBack: () => void, onClose: () => void): section.append( text( "p", - "Festival data preparation needs an internet connection. Open Lumen online and return here — one-tap download with resume, verification, and OFFLINE READY confirmation arrives with the sync stage. Your emergency floor below always works, with or without it.", + download + ? "Downloads are verified against a pinned signing key before anything changes. If the update is broken or tampered with, your current data is kept. Everything stays on this device afterwards — no internet needed." + : "No sync source is configured for this deployment. Your emergency floor below always works, with or without festival data.", ), ); + if (download) { + const button = actionButton( + download.busy ? "Downloading…" : "Download festival data", + download.onDownload, + true, + ); + if (download.busy) button.disabled = true; + section.append(button); + if (download.message) { + const note = text("p", download.message); + note.className = "status-sync-note"; + section.append(note); + } + } const buttons = document.createElement("div"); buttons.className = "status-actions"; buttons.append(actionButton("Back to status", onBack, true)); diff --git a/tests/unit/app-sync.test.ts b/tests/unit/app-sync.test.ts new file mode 100644 index 0000000..7e6ea2b --- /dev/null +++ b/tests/unit/app-sync.test.ts @@ -0,0 +1,239 @@ +/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-unsafe-call */ +/** + * App-layer sync coordinator — end-to-end with fake fetch: pointer → + * verifier → quarantine → staging → activation, exactly the phone path. + */ +import { beforeEach, describe, expect, it } from "vitest"; +// @ts-expect-error fake-indexeddb types via exports fallback +import FDBFactory from "fake-indexeddb/lib/FDBFactory"; +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 { runSync, type SyncConfig } from "../../src/app/sync.js"; +import { openSystemDB, readSystemMeta } from "../../src/data/system-meta/store.js"; +import { openUserDB } from "../../src/data/user/store.js"; +import { isQuarantined, listQuarantined } from "../../src/data/user/quarantine.js"; +import { DB } from "../../src/platform/idb/names.js"; + +const g = globalThis as unknown as Record; + +function deleteDb(name: string): Promise { + return new Promise((resolve, reject) => { + const request = (g.indexedDB as IDBFactory).deleteDatabase(name); + request.onsuccess = () => { + resolve(); + }; + request.onerror = () => { + reject(request.error ?? new Error("delete database failed")); + }; + request.onblocked = () => { + resolve(); + }; + }); +} + +const EDITION = "lumen-2026"; + +interface Origin { + readonly files: Map; + readonly fingerprint: string; + readonly derB64: string; +} + +function buildOrigin( + version = 1, + signKeyOverride?: ReturnType, +): Origin { + const keyPair = signKeyOverride ?? generateTestKeyPair(); + const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair }); + if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed"); + const pkgDir = `/editions/${EDITION}/packages/${String(version)}`; + const files = new Map(); + for (const [, f] of built.pkg.files) files.set(`${pkgDir}/${f.file}`, f.canonicalBytes); + for (const a of built.pkg.assets) files.set(`${pkgDir}/${a.file}`, a.bytesContent); + const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest)); + files.set(`${pkgDir}/manifest.json`, manifestBytes); + files.set( + `${pkgDir}/signature.json`, + new TextEncoder().encode(JSON.stringify(built.pkg.signature)), + ); + files.set( + `/latest.json`, + new TextEncoder().encode( + JSON.stringify({ + edition: EDITION, + packageVersion: version, + manifestUrl: `${pkgDir}/manifest.json`, + generatedAt: new Date(0).toISOString(), + }), + ), + ); + files.set( + `/editions/${EDITION}/latest.json`, + new TextEncoder().encode( + JSON.stringify({ + edition: EDITION, + packageVersion: version, + manifestUrl: `${pkgDir}/manifest.json`, + generatedAt: new Date(0).toISOString(), + }), + ), + ); + return { files, fingerprint: keyPair.fingerprint, derB64: keyPair.publicKeyDerBase64 }; +} + +function fakeFetch(origin: Origin, counters: { fetches: string[] } = { fetches: [] }) { + const handler = async (input: string | URL): Promise => { + const href = typeof input === "string" ? input : input.href; + const url = new URL(href); + const path = decodeURIComponent(url.pathname); + const bytes = origin.files.get(path); + if (bytes === undefined) return new Response("not found", { status: 404 }); + counters.fetches.push(path); + const copy = bytes.slice(); + return new Response(copy, { status: 200 }); + }; + return handler as unknown as typeof fetch; +} + +function config(origin: Origin): SyncConfig { + return { + edition: EDITION, + origin: "https://origin.test", + trustedKeys: { [origin.fingerprint]: origin.derB64 }, + }; +} + +beforeEach(async () => { + g.indexedDB = new FDBFactory() as unknown; + // Node's navigator has no onLine — the transport treats undefined as offline. + Object.defineProperty(globalThis, "navigator", { + value: { onLine: true }, + configurable: true, + writable: true, + }); + await Promise.all(Object.values(DB).map((name) => deleteDb(name))); +}); + +describe("app sync coordinator", () => { + it("downloads, verifies, activates, and reports OK with the new version", async () => { + const origin = buildOrigin(); + const result = await runSync({ + fetchConfig: async () => config(origin), + fetchImpl: fakeFetch(origin), + appVersion: "1.0.0", + }); + expect(result).toEqual({ status: "ok", version: 1 }); + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + system.close(); + expect(meta.activeSlot).not.toBeNull(); + expect(meta.activePackageVersion).toBe(1); + }); + + it("second run reports no-update without restaging", async () => { + const origin = buildOrigin(); + expect( + ( + await runSync({ + fetchConfig: async () => config(origin), + fetchImpl: fakeFetch(origin), + appVersion: "1.0.0", + }) + ).status, + ).toBe("ok"); + const again = await runSync({ + fetchConfig: async () => config(origin), + fetchImpl: fakeFetch(origin), + appVersion: "1.0.0", + }); + expect(again).toEqual({ status: "no-update" }); + }); + + it("rejects a package signed by an untrusted key, quarantines it, and keeps active state", async () => { + const good = buildOrigin(1); + expect( + ( + await runSync({ + fetchConfig: async () => config(good), + fetchImpl: fakeFetch(good), + appVersion: "1.0.0", + }) + ).status, + ).toBe("ok"); + + const attacker = generateTestKeyPair(); + const tampered = buildOrigin(2, attacker); + const result = await runSync({ + fetchConfig: async () => config(good), + fetchImpl: fakeFetch(tampered), + appVersion: "1.0.0", + }); + expect(result.status).toBe("rejected"); + + const system = await openSystemDB(); + const meta = await readSystemMeta(system); + system.close(); + expect(meta.activePackageVersion).toBe(1); + + const user = await openUserDB(); + expect(await isQuarantined(user, EDITION, 2)).toBe(true); + expect(await listQuarantined(user, EDITION)).toHaveLength(1); + user.close(); + }); + + it("never re-fetches files for a quarantined version (no-loop)", async () => { + const attacker = generateTestKeyPair(); + const trusted = generateTestKeyPair(); + const origin = buildOrigin(3, attacker); + const conf = { + edition: EDITION, + origin: "https://origin.test", + trustedKeys: { [trusted.fingerprint]: trusted.publicKeyDerBase64 }, + } satisfies SyncConfig; + const counters = { fetches: [] as string[] }; + const first = await runSync({ + fetchConfig: async () => conf, + fetchImpl: fakeFetch(origin, counters), + appVersion: "1.0.0", + }); + expect(first.status).toBe("rejected"); + const afterFirst = counters.fetches.length; + + const counters2 = { fetches: [] as string[] }; + const second = await runSync({ + fetchConfig: async () => conf, + fetchImpl: fakeFetch(origin, counters2), + appVersion: "1.0.0", + }); + expect(second.status).toBe("rejected"); + expect(second.status === "rejected" && second.detail).toContain("quarantined"); + // only latest.json — manifest and files are never fetched again + expect(counters2.fetches).toEqual([`/editions/${EDITION}/latest.json`]); + expect(afterFirst).toBeGreaterThan(1); + }); + + it("reports offline when transport is unavailable", async () => { + const origin = buildOrigin(); + const offlineFetch = (async () => { + throw new TypeError("fetch failed"); + }) as unknown as typeof fetch; + const result = await runSync({ + fetchConfig: async () => config(origin), + fetchImpl: offlineFetch, + appVersion: "1.0.0", + }); + // navigator.onLine is true under vitest; a failed fetch is an error path. + expect(["offline", "error"]).toContain(result.status); + }); + + it("reports not-configured when sync-config is absent", async () => { + const result = await runSync({ + fetchConfig: async () => null, + fetchImpl: fakeFetch(buildOrigin()), + appVersion: "1.0.0", + }); + expect(result.status).toBe("not-configured"); + }); +});