Compare commits
2 commits
853c19fbac
...
0e7d85b862
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e7d85b862 | ||
|
|
5b69b87394 |
9 changed files with 705 additions and 8 deletions
|
|
@ -11,6 +11,7 @@ export default tseslint.config(
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"coverage/**",
|
"coverage/**",
|
||||||
"experiments/**",
|
"experiments/**",
|
||||||
|
"scripts/serve-demo.mjs",
|
||||||
"public/sw.js",
|
"public/sw.js",
|
||||||
"share/**",
|
"share/**",
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"package:staging": "vite-node pipeline/run.ts",
|
"package:staging": "vite-node pipeline/run.ts",
|
||||||
"package:smoke": "vite-node pipeline/run.ts --smoke-only",
|
"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"
|
"ci": "npm run typecheck && npm run lint && npm run format && npm run test && npm run build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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) ———
|
||||||
|
|
|
||||||
43
scripts/gen-certs.sh
Executable file
43
scripts/gen-certs.sh
Executable file
|
|
@ -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"
|
||||||
145
scripts/serve-demo.mjs
Normal file
145
scripts/serve-demo.mjs
Normal file
|
|
@ -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}`);
|
||||||
|
});
|
||||||
|
|
@ -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
180
src/app/sync.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
239
tests/unit/app-sync.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue