Add control board web page and local server (#1503)

Step 2 of the control board MVP (MOSAIC-STACK-D-001): `serve` command starts
a loopback-only local server that serves one self-contained page and re-runs
the status scanner on each /api/board request. The page lists sessions
waiting on Jason first (errors on top), then one table per project with
plain-word states, ages, last messages, expandable detail rows, per-project
hide-offline, and a 10-second auto-refresh with pause.

Tests: control-board 33/33 (10 new: loopback rules, host refusal, all routes,
per-request rescan, 500 path, CLI refusals, live serve, page escaping guard);
registry 69/69 unchanged. Receipt:
docs/plans/reviews/2026-09-12_control-board-step2-review.md.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 07:58:38 -05:00
co-authored by Claude Fable 5.1
parent b9f59a5903
commit ebedd1281e
10 changed files with 914 additions and 26 deletions
+75
View File
@@ -0,0 +1,75 @@
// Control board step 2: a tiny local web server. No dependencies, no auth.
// It only ever binds to a loopback address (fail closed otherwise).
//
// GET / the page (src/page.html)
// GET /api/board re-runs the scanner and returns index.json as JSON
// GET /healthz {"ok":true}
//
// Every /api/board request rescans, so the page is never staler than its
// refresh timer. The scan rewrites the derived board files as a side effect.
import { createServer as createHttpServer } from "node:http";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { isIP } from "node:net";
import { scan, ConfigError } from "./scan.mjs";
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
export function isLoopbackHost(host) {
if (LOOPBACK.has(host)) return true;
return isIP(host) === 4 && host.startsWith("127.");
}
export function loadPage(path = join(import.meta.dirname, "page.html")) {
return readFileSync(path, "utf8");
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, page = loadPage() }) {
return createHttpServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { "content-type": "text/plain" });
return res.end("method not allowed\n");
}
if (url.pathname === "/" || url.pathname === "/index.html") {
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
return res.end(page);
}
if (url.pathname === "/api/board") {
let index;
try {
index = scan(specs, { boardDir, isAlive, now });
} catch (err) {
res.writeHead(500, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify({ error: err.message }) + "\n");
}
res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
return res.end(JSON.stringify(index) + "\n");
}
if (url.pathname === "/favicon.ico") {
res.writeHead(204);
return res.end();
}
if (url.pathname === "/healthz") {
res.writeHead(200, { "content-type": "application/json" });
return res.end('{"ok":true}\n');
}
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found\n");
});
}
// Resolves to the listening server. Refuses any non-loopback host.
export async function startServer({ host = "127.0.0.1", port = 7331, ...rest }) {
if (!isLoopbackHost(host)) throw new ConfigError(`refusing to bind to non-loopback host: ${host} (no auth in the MVP)`);
const server = createServer(rest);
return new Promise((resolvePromise, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolvePromise(server);
});
});
}