diff --git a/eslint.config.js b/eslint.config.js index 0c0123e..ea26535 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,7 +11,6 @@ export default tseslint.config( "node_modules/**", "coverage/**", "experiments/**", - "scripts/serve-demo.mjs", "public/sw.js", "share/**", ], diff --git a/package.json b/package.json index 121d71a..bceab54 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,6 @@ "preview": "vite preview", "package:staging": "vite-node pipeline/run.ts", "package:smoke": "vite-node pipeline/run.ts --smoke-only", - "demo:certs": "bash scripts/gen-certs.sh", - "demo:serve": "node scripts/serve-demo.mjs", "ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build" }, "devDependencies": { diff --git a/pipeline/run.ts b/pipeline/run.ts index d7c5cd3..4b3e3f9 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, dirname } from "node:path"; +import { join } 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: arg("min-app-version", "1.0.0"), maxAppVersion: null }, + appCompatibility: { minAppVersion: "1.0.0", maxAppVersion: null }, content: { emergency, schedule: { ...schedule, events } as unknown as PipelineInput["content"]["schedule"], @@ -216,10 +216,6 @@ 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); @@ -235,10 +231,7 @@ 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/scripts/gen-certs.sh b/scripts/gen-certs.sh deleted file mode 100755 index a1c7433..0000000 --- a/scripts/gen-certs.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -# Sovereign demo certs — private CA + server cert, generated locally. -# Phones install rootCA.pem once; the browser then trusts the server. -# Usage: scripts/gen-certs.sh [host-or-ip ...] -# (extra SAN entries; localhost/127.0.0.1/10.42.0.1 always included) -set -euo pipefail -DIR="$(cd "$(dirname "$0")/.." && pwd)/instance/certs" -mkdir -p "$DIR" - -SANS=("localhost" "127.0.0.1" "10.42.0.1") -# auto-include current non-loopback IPv4 addresses -while read -r ip; do [[ -n "$ip" ]] && SANS+=("$ip"); done < <( - ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 -) -for extra in "$@"; do SANS+=("$extra"); done - -for s in "${SANS[@]}"; do - if [[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - SAN_STR+="IP:$s," - else - SAN_STR+="DNS:$s," - fi -done -SAN_STR="DNS:localhost,${SAN_STR%,}" - -if [[ ! -f "$DIR/rootCA-key.pem" ]]; then - openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ - -keyout "$DIR/rootCA-key.pem" -out "$DIR/rootCA.pem" -days 825 \ - -subj "/CN=Lumen Demo CA/O=Lumen" \ - -addext "basicConstraints=critical,CA:TRUE" \ - -addext "keyUsage=critical,keyCertSign,cRLSign" - echo "created CA: $DIR/rootCA.pem (install this on phones)" -fi - -openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \ - -keyout "$DIR/server-key.pem" -out "$DIR/server.csr" \ - -subj "/CN=Lumen Demo Server/O=Lumen" -openssl x509 -req -in "$DIR/server.csr" \ - -CA "$DIR/rootCA.pem" -CAkey "$DIR/rootCA-key.pem" -CAcreateserial \ - -out "$DIR/server.pem" -days 397 \ - -extfile <(printf "subjectAltName=%s\nextendedKeyUsage=serverAuth\n" "$SAN_STR") -rm -f "$DIR/server.csr" -echo "created server cert: $DIR/server.pem SAN: $SAN_STR" diff --git a/scripts/serve-demo.mjs b/scripts/serve-demo.mjs deleted file mode 100644 index 5f6e6a1..0000000 --- a/scripts/serve-demo.mjs +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env node -/* eslint-disable no-console -- this is the demo server CLI; stdout is its interface */ -/** - * Sovereign demo server — HTTPS file server for the phone showcase. - * Serves the built app shell (dist/) and the signed origin tree - * (instance/origin/) on one HTTPS port, no third parties involved. - * - * Routes: - * /editions/** → origin tree (immutable packages) - * /latest.json → origin pointer (mutable) - * /sync-config.json → pinned trust config for the app - * /rootCA.pem → the CA cert phones must install to trust us - * everything else → dist/ (app shell, SPA fallback to index.html) - * - * Usage: node scripts/serve-demo.mjs [--port 8443] [--host 0.0.0.0] - * [--app dist] [--origin instance/origin] [--certs instance/certs] - * [--edition lumen-2026] - */ -import { createServer } from "node:https"; -import { createHash } from "node:crypto"; -import { readFileSync, existsSync, statSync } from "node:fs"; -import { join, normalize, extname } from "node:path"; - -const argv = process.argv.slice(2); -function arg(name, dflt) { - const i = argv.indexOf(`--${name}`); - return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; -} -const port = Number(arg("port", "8443")); -const host = arg("host", "0.0.0.0"); -const appDir = arg("app", "dist"); -const originDir = arg("origin", "instance/origin"); -const certsDir = arg("certs", "instance/certs"); -const edition = arg("edition", "lumen-2026"); - -const keyPath = join(certsDir, "server-key.pem"); -const certPath = join(certsDir, "server.pem"); -const caPath = join(certsDir, "rootCA.pem"); -for (const p of [keyPath, certPath, caPath]) { - if (!existsSync(p)) { - console.error(`missing ${p} — run scripts/gen-certs.sh first`); - process.exit(1); - } -} -if (!existsSync(join(appDir, "index.html"))) { - console.error(`missing ${appDir}/index.html — run npm run build first`); - process.exit(1); -} - -const MIME = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".pem": "application/x-pem-file", - ".png": "image/png", - ".svg": "image/svg+xml", - ".ico": "image/x-icon", - ".webmanifest": "application/manifest+json", -}; - -function send(res, code, body, type) { - res.writeHead(code, { - "content-type": type, - "cache-control": "no-store", - "access-control-allow-origin": "*", - }); - res.end(body); -} - -function sendFile(res, path) { - const data = readFileSync(path); - const type = MIME[extname(path)] ?? "application/octet-stream"; - // Immutable by contract: content-addressed package files under /editions/. - const immutable = path.includes("/packages/"); - res.writeHead(200, { - "content-type": type, - "cache-control": immutable ? "public, max-age=31536000, immutable" : "no-store", - }); - res.end(data); -} - -function syncConfig() { - // Pinned trust for the app: fingerprint -> SPKI DER base64. - // Test key published by the pipeline next to the package (demo mode only). - const latest = JSON.parse(readFileSync(join(originDir, "latest.json"), "utf8")); - const pkgDir = join( - originDir, - "editions", - latest.edition, - "packages", - String(latest.packageVersion), - ); - const pem = readFileSync(join(pkgDir, "publicKey.pem"), "utf8"); - const derB64 = pem - .replace(/-----BEGIN PUBLIC KEY-----/g, "") - .replace(/-----END PUBLIC KEY-----/g, "") - .replace(/\s/g, ""); - const digest = createHash("sha256").update(Buffer.from(derB64, "base64")).digest("hex"); - return JSON.stringify({ - edition: latest.edition, - origin: "https://REPLACE_HOST", - trustedKeys: { [`sha256:${digest}`]: derB64 }, - }); -} - -const server = createServer( - { key: readFileSync(keyPath), cert: readFileSync(certPath) }, - (req, res) => { - const url = new URL(req.url ?? "/", `https://${req.headers.host ?? "localhost"}`); - const path = normalize(decodeURIComponent(url.pathname)); - console.log(`${req.method} ${path}`); - - if (path === "/sync-config.json") { - try { - const hostHeader = req.headers.host ?? `localhost:${port}`; - const conf = syncConfig().replace("https://REPLACE_HOST", `https://${hostHeader}`); - return send(res, 200, conf, MIME[".json"]); - } catch (e) { - return send(res, 500, JSON.stringify({ error: String(e) }), MIME[".json"]); - } - } - if (path === "/rootCA.pem") return sendFile(res, caPath); - - if (path.startsWith("/editions/") || path === "/latest.json") { - const filePath = join(originDir, path); - if (filePath.startsWith(originDir) && existsSync(filePath) && statSync(filePath).isFile()) - return sendFile(res, filePath); - return send(res, 404, "not found", "text/plain"); - } - - const appPath = join(appDir, path === "/" ? "index.html" : path); - if (appPath.startsWith(appDir) && existsSync(appPath) && statSync(appPath).isFile()) - return sendFile(res, appPath); - // SPA fallback - return sendFile(res, join(appDir, "index.html")); - }, -); - -server.listen(port, host, () => { - console.log(`Lumen demo server (edition ${edition})`); - console.log(` app : ${appDir}`); - console.log(` origin : ${originDir}`); - console.log(` listening on https://${host}:${port}`); -}); diff --git a/src/app/main.ts b/src/app/main.ts index 2ac7271..c80e177 100644 --- a/src/app/main.ts +++ b/src/app/main.ts @@ -23,7 +23,6 @@ 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"; @@ -124,11 +123,14 @@ 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: () => { - showPrepGuidance(); + dialog.replaceChildren(createPrepGuidanceView(back, close)); }, onRestore: report.canRestore ? () => { @@ -143,64 +145,6 @@ 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 deleted file mode 100644 index 1917f6d..0000000 --- a/src/app/sync.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * 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 c72ffd1..c850da6 100644 --- a/src/ui/views/status/status.ts +++ b/src/ui/views/status/status.ts @@ -168,15 +168,7 @@ 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, - download: { - readonly onDownload: () => void; - readonly busy: boolean; - readonly message: string | null; - } | null = null, -): HTMLElement { +export function createPrepGuidanceView(onBack: () => void, onClose: () => void): HTMLElement { const section = document.createElement("section"); section.className = "status-view"; section.setAttribute("aria-labelledby", "prep-heading"); @@ -187,25 +179,9 @@ export function createPrepGuidanceView( section.append( text( "p", - 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.", + "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.", ), ); - 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 deleted file mode 100644 index 7e6ea2b..0000000 --- a/tests/unit/app-sync.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -/* 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"); - }); -});