Some checks failed
ci / check (push) Has been cancelled
serve-demo.mjs serves dist/ (app shell, SPA fallback) and instance/origin/ (signed packages + latest.json) on one HTTPS port; generates /sync-config.json with the pinned signing-key fingerprint from the published package and offers /rootCA.pem for phone trust. gen-certs.sh creates a local ECDSA CA + server cert with SANs for localhost, hotspot 10.42.0.1, and current interface IPs. No tunnels, no third parties: everything runs on this laptop. npm run demo:certs / demo:serve.
145 lines
5.2 KiB
JavaScript
145 lines
5.2 KiB
JavaScript
#!/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}`);
|
|
});
|