From 5b69b87394fa3ff6b209bf4e1736733f9f3b7f2b Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Fri, 2 Oct 2026 12:40:17 -0500 Subject: [PATCH 1/2] Showcase: user-initiated sync coordinator wired into Status screen runSync() composes transport -> verifier -> quarantine no-loop -> staging -> activation as one user action (the phone tap). Trust comes from same-origin /sync-config.json (edition + origin + pinned fingerprint->SPKI map). Prep guidance view gains a real Download button with phase messages and outcome notes (ok/no-update/offline/rejected/not-configured). Pipeline now writes the per-edition pointer /editions//latest.json the HttpTransport consumes, and takes --min-app-version so staging packages pass shell compatibility. 6 new end-to-end tests (fake fetch + fake-indexedDB): activate, no-update, untrusted-key rejection keeps v1 + quarantines v2, no-loop fetches ONLY latest.json on a quarantined pointer. Full CI green: 291 tests. --- pipeline/run.ts | 11 +- src/app/main.ts | 64 ++++++++- src/app/sync.ts | 180 +++++++++++++++++++++++++ src/ui/views/status/status.ts | 28 +++- tests/unit/app-sync.test.ts | 239 ++++++++++++++++++++++++++++++++++ 5 files changed, 514 insertions(+), 8 deletions(-) create mode 100644 src/app/sync.ts create mode 100644 tests/unit/app-sync.test.ts 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"); + }); +}); From 0e7d85b8629ed07e9558e66bbd0cc3eb146b2582 Mon Sep 17 00:00:00 2001 From: Lumen Stage1 Date: Fri, 2 Oct 2026 12:40:17 -0500 Subject: [PATCH 2/2] Showcase: sovereign HTTPS demo server + local CA serve-demo.mjs serves dist/ (app shell, SPA fallback) and instance/origin/ (signed packages + latest.json) on one HTTPS port; generates /sync-config.json with the pinned signing-key fingerprint from the published package and offers /rootCA.pem for phone trust. gen-certs.sh creates a local ECDSA CA + server cert with SANs for localhost, hotspot 10.42.0.1, and current interface IPs. No tunnels, no third parties: everything runs on this laptop. npm run demo:certs / demo:serve. --- eslint.config.js | 1 + package.json | 2 + scripts/gen-certs.sh | 43 ++++++++++++ scripts/serve-demo.mjs | 145 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+) create mode 100755 scripts/gen-certs.sh create mode 100644 scripts/serve-demo.mjs diff --git a/eslint.config.js b/eslint.config.js index ea26535..0c0123e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -11,6 +11,7 @@ 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 bceab54..121d71a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "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/scripts/gen-certs.sh b/scripts/gen-certs.sh new file mode 100755 index 0000000..a1c7433 --- /dev/null +++ b/scripts/gen-certs.sh @@ -0,0 +1,43 @@ +#!/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 new file mode 100644 index 0000000..5f6e6a1 --- /dev/null +++ b/scripts/serve-demo.mjs @@ -0,0 +1,145 @@ +#!/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}`); +});