Compare commits

..

No commits in common. "0e7d85b8629ed07e9558e66bbd0cc3eb146b2582" and "853c19fbac68b1853f94eb9aa128425ed0c23f66" have entirely different histories.

9 changed files with 8 additions and 705 deletions

View file

@ -11,7 +11,6 @@ export default tseslint.config(
"node_modules/**",
"coverage/**",
"experiments/**",
"scripts/serve-demo.mjs",
"public/sw.js",
"share/**",
],

View file

@ -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": {

View file

@ -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 {
// <out>/editions/<edition>/packages/<v> → <out>/editions/<edition>
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/<ed>/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) ———

View file

@ -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"

View file

@ -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}`);
});

View file

@ -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<void> {
const report = await evaluateBootReadiness();
latestReport = report;

View file

@ -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:<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,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));

View file

@ -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<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");
});
});