import { createHash, randomUUID } from "node:crypto"; import { createReadStream, type Dirent, lstatSync, readdirSync, readFileSync, realpathSync, rmSync, } from "node:fs"; import { createServer, type IncomingMessage, type Server, type ServerResponse, } from "node:http"; import path from "node:path"; import { type BoardItemState, type BoardProposal, type BoardState, readBoardState, updateBoardState, writeBoardState, } from "./board-state.ts"; import { parseSystemConfig } from "./documents.ts"; import { writePlanStatus } from "./plan-status.ts"; import { loadProjectIndex, type ProjectIndex } from "./project-index.ts"; const BODY_LIMIT = 64 * 1024; const SIGNATURE_BYTE_LIMIT = 1024 * 1024; const COOKIE = "the_system_board"; const STATES = ["todo", "done", "open"] as const; const STATUSES = ["draft", "approved"] as const; export type BoardServer = Readonly<{ url: string; close(): void }>; export type BoardSignature = Readonly<{ documents: string; board: string }>; export type BoardRenderer = ( index: ProjectIndex, board: BoardState, proposals: readonly BoardProposal[], revision: string, ) => Readonly<{ directory: string }>; export function boardSignature(root: string): BoardSignature { const documents = createHash("sha256"); const board = createHash("sha256"); const systemRoot = path.join(root, ".system"); for (const file of governedFiles(systemRoot)) { const relative = path.relative(systemRoot, file).split(path.sep).join("/"); const digest = relative === "board.json" ? board : documents; digest.update(`${relative}\u0000`); const stats = lstatSync(file, { throwIfNoEntry: false }); if (!stats?.isFile()) continue; if (stats.size > SIGNATURE_BYTE_LIMIT) { digest.update(`${stats.size}:${stats.mtimeMs}`); continue; } digest.update(readFileSync(file)); } return { documents: documents.digest("hex"), board: board.digest("hex") }; } function governedFiles(directory: string): readonly string[] { let entries: Dirent[]; try { entries = readdirSync(directory, { withFileTypes: true }); } catch { return []; } return entries .sort((left, right) => left.name.localeCompare(right.name)) .flatMap((entry) => { const target = path.join(directory, entry.name); if (entry.isSymbolicLink()) return []; if (entry.isDirectory()) return governedFiles(target); return entry.isFile() ? [target] : []; }); } type BoardSite = Readonly<{ signature: BoardSignature; directory: string }>; export async function startBoardServer( root: string, proposals: Map, render: BoardRenderer, ): Promise { const token = `${randomUUID()}-${randomUUID()}`; const cookieName = `${COOKIE}_${token.slice(0, 8)}`; const stale = new Set(); let site = renderSite(root, proposals, render); let renderError: string | undefined; const ensureSite = (): BoardSite => { const signature = boardSignature(root); if ( signature.documents === site.signature.documents && signature.board === site.signature.board ) return site; const previous = site; try { site = renderSite(root, proposals, render, signature); renderError = undefined; } catch (error) { renderError = error instanceof Error ? error.message : String(error); return previous; } discard(previous.directory, stale); return site; }; let mutation = Promise.resolve(); const enqueue = (work: () => T): Promise => { const result = mutation.then(work, work); mutation = result.then( () => undefined, () => undefined, ); return result; }; const server: Server = createServer((request, response) => { void dispatch(request, response, { root, proposals, token, cookieName, enqueue, ensureSite, currentSite: () => site, renderProblem: () => renderError, }); }); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); const address = server.address(); if (!address || typeof address === "string") throw new Error("Failed to bind System board server"); return { url: `http://127.0.0.1:${address.port}/?k=${token}`, close() { server.closeAllConnections(); server.close(); discard(site.directory, stale); for (const directory of [...stale]) discard(directory, stale); }, }; } function renderSite( root: string, proposals: Map, render: BoardRenderer, signature = boardSignature(root), ): BoardSite { const index = loadProjectIndex(root); const board = readBoardState(index.root, index.prefix); const output = render( index, board, [...proposals.values()].sort((left, right) => left.id.localeCompare(right.id), ), signature.documents, ); return { signature, directory: output.directory }; } function discard(directory: string, stale: Set): void { try { rmSync(directory, { recursive: true, force: true }); stale.delete(directory); } catch { stale.add(directory); } } type DispatchState = Readonly<{ root: string; proposals: Map; token: string; cookieName: string; enqueue(work: () => T): Promise; ensureSite(): BoardSite; currentSite(): BoardSite; renderProblem(): string | undefined; }>; async function dispatch( request: IncomingMessage, response: ServerResponse, state: DispatchState, ): Promise { try { const url = new URL(request.url ?? "/", "http://127.0.0.1"); const queryToken = url.searchParams.get("k"); const cookieToken = cookie(request.headers.cookie ?? "", state.cookieName); if (queryToken === state.token) { response.statusCode = 303; response.setHeader("Location", url.pathname); response.setHeader( "Set-Cookie", `${state.cookieName}=${state.token}; HttpOnly; SameSite=Strict; Path=/`, ); response.end(); return; } if (cookieToken !== state.token) { respondJson(response, 403, { error: "Forbidden" }); return; } if ( request.method === "POST" && request.headers.origin !== `http://${request.headers.host}` ) { respondJson(response, 403, { error: "Invalid board origin" }); return; } if (request.method === "POST" && url.pathname === "/api/state") { const input = await readJsonBody(request); const result = await state.enqueue(() => applyState(state.root, input, "manual"), ); respondJson(response, 200, result); return; } if (request.method === "POST" && url.pathname === "/api/status") { const input = await readJsonBody(request); const result = await state.enqueue(() => applyStatus(state.root, input)); respondJson(response, 200, result); return; } if (request.method === "POST" && url.pathname === "/api/proposal") { const input = await readJsonBody(request); if (typeof input.id !== "string" || typeof input.state !== "string") throw new Error("Expected assessment proposal with id and state"); const proposal = state.proposals.get(input.id); if (!proposal || proposal.state !== input.state) throw new Error(`No matching assessment proposal: ${input.id}`); const result = await state.enqueue(() => applyState(state.root, input, "assess"), ); state.proposals.delete(input.id); respondJson(response, 200, result); return; } if (request.method !== "GET") { respondJson(response, 405, { error: "Method not allowed" }); return; } if (url.pathname === "/api/state") { respondJson(response, 200, snapshot(state)); return; } const site = isDocument(url.pathname) ? state.ensureSite() : state.currentSite(); serveFile(response, site.directory, url.pathname); } catch (error) { respondJson(response, 400, { error: error instanceof Error ? error.message : String(error), }); } } const isDocument = (pathname: string) => pathname === "/" || pathname.endsWith(".html"); function snapshot(state: DispatchState) { const prefix = parseSystemConfig( readFileSync(path.join(state.root, ".system/config.json"), "utf8"), ).prefix; const board = readBoardState(state.root, prefix); const problem = state.renderProblem(); return { revision: boardSignature(state.root).documents, items: board.items, ...(problem ? { problem } : {}), }; } function applyState( root: string, input: Record, source: "manual" | "assess", ) { if ( Object.keys(input).some((key) => key !== "id" && key !== "state") || typeof input.id !== "string" || typeof input.state !== "string" || !STATES.includes(input.state as (typeof STATES)[number]) ) throw new Error("Expected board update with id and state"); const index = loadProjectIndex(root); const current = readBoardState(index.root, index.prefix); const updated = updateBoardState( current, index.documents, input.id, input.state as BoardItemState, source, ); writeBoardState(index.root, updated); return { items: updated.items }; } function applyStatus(root: string, input: Record) { if ( Object.keys(input).some((key) => key !== "id" && key !== "status") || typeof input.id !== "string" || typeof input.status !== "string" || !STATUSES.includes(input.status as (typeof STATUSES)[number]) ) throw new Error("Expected plan update with id and status"); const index = loadProjectIndex(root); writePlanStatus(index, input.id, input.status as (typeof STATUSES)[number]); return { id: input.id, status: input.status }; } function readJsonBody( request: IncomingMessage, ): Promise> { return new Promise((resolve, reject) => { let source = ""; request.setEncoding("utf8"); request.on("data", (chunk: string) => { source += chunk; if (Buffer.byteLength(source) > BODY_LIMIT) { request.destroy(); reject(new Error("Board request body is too large")); } }); request.on("end", () => { try { const parsed = JSON.parse(source); if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) throw new Error("Board request body must be an object"); resolve(parsed as Record); } catch (error) { reject(error); } }); request.on("error", reject); }); } function serveFile( response: ServerResponse, directory: string, pathname: string, ): void { const relative = decodeURIComponent( pathname === "/" ? "index.html" : pathname.slice(1), ); const target = path.resolve(directory, relative); const inside = path.relative(directory, target); if ( inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside) ) { respondJson(response, 404, { error: "Not found" }); return; } const stats = lstatSync(target, { throwIfNoEntry: false }); if (!stats?.isFile() || stats.isSymbolicLink()) { respondJson(response, 404, { error: "Not found" }); return; } const realRoot = realpathSync(directory); const realTarget = realpathSync(target); const realInside = path.relative(realRoot, realTarget); if ( realInside === ".." || realInside.startsWith(`..${path.sep}`) || path.isAbsolute(realInside) ) { respondJson(response, 404, { error: "Not found" }); return; } response.statusCode = 200; response.setHeader("Content-Type", contentType(target)); response.setHeader("Cache-Control", "no-store"); createReadStream(target).pipe(response); } function contentType(file: string): string { switch (path.extname(file).toLowerCase()) { case ".html": return "text/html; charset=utf-8"; case ".js": return "text/javascript; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".svg": return "image/svg+xml"; case ".png": return "image/png"; case ".jpg": case ".jpeg": return "image/jpeg"; case ".gif": return "image/gif"; case ".webp": return "image/webp"; case ".avif": return "image/avif"; case ".mp4": return "video/mp4"; case ".webm": return "video/webm"; case ".md": return "text/markdown; charset=utf-8"; default: return "application/octet-stream"; } } function respondJson( response: ServerResponse, status: number, value: unknown, ): void { if (response.headersSent) return; response.statusCode = status; response.setHeader("Content-Type", "application/json; charset=utf-8"); response.setHeader("Cache-Control", "no-store"); response.end(JSON.stringify(value)); } function cookie(source: string, name: string): string | undefined { for (const part of source.split(";")) { const [key, ...value] = part.trim().split("="); if (key === name) return value.join("="); } return undefined; }