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