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/<ed>/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.
This commit is contained in:
Lumen Stage1 2026-10-02 12:40:17 -05:00
commit 5b69b87394
5 changed files with 514 additions and 8 deletions

View file

@ -11,7 +11,7 @@
* Trace: IMPLEMENTATION-CONTRACT.md Stage 6, SPIKE-04 §3 gate 5, ARCH 18.7. * Trace: IMPLEMENTATION-CONTRACT.md Stage 6, SPIKE-04 §3 gate 5, ARCH 18.7.
*/ */
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; 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 { buildPackage } from "./package.js";
import { generateTestKeyPair, fingerprintFromPublicPem } from "./sign.js"; import { generateTestKeyPair, fingerprintFromPublicPem } from "./sign.js";
import { sha256Hex } from "./hash.js"; import { sha256Hex } from "./hash.js";
@ -98,7 +98,7 @@ const input: PipelineInput = {
schemaVersion: 1, schemaVersion: 1,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
festival: { name: "SolarPunk Summit 2026", timezone: FEST_TZ, startUtc, endUtc }, 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: { content: {
emergency, emergency,
schedule: { ...schedule, events } as unknown as PipelineInput["content"]["schedule"], 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) ——— // ——— 5: upload immutable files + flip latest.json (local dir = origin layout §37) ———
const pkgDir = join(outDir, "editions", edition, "packages", String(version)); const pkgDir = join(outDir, "editions", edition, "packages", String(version));
function originEditionDir(packageDirectory: string): string {
// <out>/editions/<edition>/packages/<v> → <out>/editions/<edition>
return dirname(dirname(packageDirectory));
}
mkdirSync(join(pkgDir, "assets"), { recursive: true }); mkdirSync(join(pkgDir, "assets"), { recursive: true });
for (const [, f] of pkg.files) { for (const [, f] of pkg.files) {
writeFileSync(join(pkgDir, f.file), f.canonicalBytes); 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, "publicKey.pem"), kp.publicKeyPem);
writeFileSync(join(pkgDir, "emergency-floor.json"), JSON.stringify(pkg.emergencyFloor, null, 2)); writeFileSync(join(pkgDir, "emergency-floor.json"), JSON.stringify(pkg.emergencyFloor, null, 2));
// Mutable pointer — last write, so immutable files always exist before flip. // 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/<ed>/latest.json).
writeFileSync(join(outDir, "latest.json"), JSON.stringify(pkg.latest, null, 2)); 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`); log("upload", `wrote immutable tree under ${pkgDir}/ + latest.json flip`);
// ——— 6: smoke — verify what we just uploaded (key known from this run) ——— // ——— 6: smoke — verify what we just uploaded (key known from this run) ———

View file

@ -23,6 +23,7 @@ import { openUserDB, addFavorite, removeFavorite, putPrefs } from "../data/user/
import { createScheduleView } from "../ui/views/schedule/schedule.js"; import { createScheduleView } from "../ui/views/schedule/schedule.js";
import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedule/schedule.js"; import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedule/schedule.js";
import { restorePreviousSlot } from "../sync/activation.js"; import { restorePreviousSlot } from "../sync/activation.js";
import { runSync } from "./sync.js";
import { createLayout, setActiveNav } from "./layout.js"; import { createLayout, setActiveNav } from "./layout.js";
import { Router, routeForPath, normalizePath } from "../ui/router/router.js"; import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
import { createHomeView } from "../ui/views/home/home.js"; import { createHomeView } from "../ui/views/home/home.js";
@ -123,14 +124,11 @@ function mount(): { router: Router; cleanup: () => void } {
const close = (): void => { const close = (): void => {
dialog.close(); dialog.close();
}; };
const back = (): void => {
if (latestReport) showStatus(latestReport);
};
dialog.replaceChildren( dialog.replaceChildren(
createStatusView(report, { createStatusView(report, {
onCheckData: () => void refreshReadiness(true), onCheckData: () => void refreshReadiness(true),
onGetData: () => { onGetData: () => {
dialog.replaceChildren(createPrepGuidanceView(back, close)); showPrepGuidance();
}, },
onRestore: report.canRestore onRestore: report.canRestore
? () => { ? () => {
@ -145,6 +143,64 @@ function mount(): { router: Router; cleanup: () => void } {
if (!dialog.open) dialog.showModal(); 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<void> { async function refreshReadiness(openAfter: boolean): Promise<void> {
const report = await evaluateBootReadiness(); const report = await evaluateBootReadiness();
latestReport = report; latestReport = report;

180
src/app/sync.ts Normal file
View file

@ -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:<hex>") → SPKI DER base64. */
readonly trustedKeys: Readonly<Record<string, string>>;
}
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<SyncConfig | null>;
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<string, unknown>;
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<string, unknown>).every((k) => typeof k === "string")
);
}
export function defaultConfigUrl(): string {
return "/sync-config.json";
}
export async function loadSyncConfig(
fetchImpl: typeof fetch,
url: string = defaultConfigUrl(),
): Promise<SyncConfig | null> {
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<Record<string, string>>): TrustedKeySet {
const map = new Map<string, Uint8Array>();
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<number> {
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<SyncOutcome> {
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();
}
}

View file

@ -168,7 +168,15 @@ export function createStatusView(report: BootReadinessReport, actions: StatusAct
} }
/** Preparation guidance shown for Get/Continue/Restore — sync flow lands in Stage 15. */ /** 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"); const section = document.createElement("section");
section.className = "status-view"; section.className = "status-view";
section.setAttribute("aria-labelledby", "prep-heading"); section.setAttribute("aria-labelledby", "prep-heading");
@ -179,9 +187,25 @@ export function createPrepGuidanceView(onBack: () => void, onClose: () => void):
section.append( section.append(
text( text(
"p", "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"); const buttons = document.createElement("div");
buttons.className = "status-actions"; buttons.className = "status-actions";
buttons.append(actionButton("Back to status", onBack, true)); buttons.append(actionButton("Back to status", onBack, true));

239
tests/unit/app-sync.test.ts Normal file
View file

@ -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<string, unknown>;
function deleteDb(name: string): Promise<void> {
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<string, Uint8Array>;
readonly fingerprint: string;
readonly derB64: string;
}
function buildOrigin(
version = 1,
signKeyOverride?: ReturnType<typeof generateTestKeyPair>,
): 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<string, Uint8Array>();
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<Response> => {
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");
});
});