Showcase: sovereign HTTPS demo server + local CA
Some checks failed
ci / check (push) Has been cancelled
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.
This commit is contained in:
parent
5b69b87394
commit
0e7d85b862
4 changed files with 191 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ export default tseslint.config(
|
|||
"node_modules/**",
|
||||
"coverage/**",
|
||||
"experiments/**",
|
||||
"scripts/serve-demo.mjs",
|
||||
"public/sw.js",
|
||||
"share/**",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
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}`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue