#!/usr/bin/env node // SPDX-License-Identifier: Apache-2.0 // © 2026 Lutar, Stephen P. — SZL Holdings // ORCID: 0009-0001-0110-4173 // // serve.ts — HTTP server mode for a11oy (`a11oy serve --port 8080`). // // a11oy ships a CLI plus a small read/verify HTTP surface so a Kubernetes // deployment can probe /healthz and /readyz, and so the web SPA can read the // receipt ledger over HTTP. Every route below calls the real receipt-substrate // and policy-gate library code — there is no mocked data path. // // Routes: // GET /healthz -> 200 {status, sha, ts} // GET /readyz -> 200 if the ledger file is readable, else 503 // GET /v1/ledger?limit=N -> last N receipts from the ledger as JSON // GET /v1/ledger/{hash} -> single receipt by receipt_id or merkle_root // POST /v1/verify {ledger:[...]} -> {valid, broken_at} via verifyChain // GET /v1/policy/example -> sample valid request body for /v1/policy/evaluate // POST /v1/policy/evaluate -> {decision, gate, receipt_hash} via the // thresholdPolicySeverity gate (real HMAC) // Accepts: {action:{...}} OR flat {severity,...} // // No network calls beyond accepting inbound HTTP. The git SHA is read from the // A11OY_GIT_SHA env var (set at build time) or resolved from the local git // checkout at boot, falling back to "unknown" rather than fabricating a value. // // Authored for SZL Holdings. Signed-off per repository DCO. import * as http from "node:http"; import * as fs from "node:fs"; import { execFileSync } from "node:child_process"; import { readReceiptJsonl, verifyChain, type OperationalReceipt, } from "./index.ts"; import { thresholdPolicySeverityGate } from "../../policy/src/gates/thresholdPolicySeverity_gate.ts"; const DEFAULT_LEDGER = "/tmp/a11oy-operational-receipts.jsonl"; export interface ServeConfig { readonly port: number; readonly host: string; readonly ledgerPath: string; readonly sha: string; } interface JsonResponse { readonly status: number; readonly body: unknown; } /** Resolve the git SHA without fabricating one. */ export function resolveGitSha(): string { const fromEnv = process.env.A11OY_GIT_SHA; if (fromEnv && fromEnv.trim()) return fromEnv.trim(); try { return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(); } catch { return "unknown"; } } /** Build the runtime config from argv + environment. */ export function parseServeConfig(argv: readonly string[]): ServeConfig { const args = new Map(); for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; if (!token.startsWith("--")) continue; const key = token.slice(2); const value = argv[i + 1]; if (value === undefined || value.startsWith("--")) { throw new Error(`Missing value for --${key}`); } args.set(key, value); i += 1; } const portRaw = args.get("port") ?? process.env.A11OY_PORT ?? "8080"; const port = Number.parseInt(portRaw, 10); // Port 0 is permitted: it instructs the OS to assign an ephemeral port, // which the integration tests rely on to avoid binding a fixed port. if (!Number.isInteger(port) || port < 0 || port > 65535) { throw new Error(`Invalid --port '${portRaw}'; expected an integer in [0,65535]`); } const ledgerPath = args.get("ledger") ?? process.env.A11OY_PROOF_LEDGER_PATH ?? DEFAULT_LEDGER; const host = args.get("host") ?? process.env.A11OY_HOST ?? "0.0.0.0"; return { port, host, ledgerPath, sha: resolveGitSha() }; } function clampLimit(raw: string | null): number { if (raw === null) return 20; const parsed = Number.parseInt(raw, 10); if (!Number.isInteger(parsed) || parsed < 1) return 20; return Math.min(parsed, 1000); } /** * Resolve a route to a JSON response. Pure with respect to the request: all * ledger reads go through the real readReceiptJsonl, all verification through * the real verifyChain, all policy decisions through the real gate. */ export function handleRoute( method: string, url: URL, body: string, config: ServeConfig, ): JsonResponse { // --- GET /healthz --------------------------------------------------------- if (method === "GET" && url.pathname === "/healthz") { return { status: 200, body: { status: "ok", sha: config.sha, ts: new Date().toISOString() }, }; } // --- GET /readyz ---------------------------------------------------------- if (method === "GET" && url.pathname === "/readyz") { try { // A readable ledger is the readiness contract. An absent file is treated // as an empty-but-readable ledger (genesis state), which is ready. if (fs.existsSync(config.ledgerPath)) { fs.accessSync(config.ledgerPath, fs.constants.R_OK); readReceiptJsonl(config.ledgerPath); } return { status: 200, body: { status: "ready", ledger: config.ledgerPath } }; } catch (error) { return { status: 503, body: { status: "not_ready", reason: (error as Error).message, ledger: config.ledgerPath }, }; } } // --- GET /v1/ledger?limit=N ---------------------------------------------- if (method === "GET" && url.pathname === "/v1/ledger") { const limit = clampLimit(url.searchParams.get("limit")); const chain = readReceiptJsonl(config.ledgerPath); const tail = chain.slice(Math.max(0, chain.length - limit)); return { status: 200, body: { count: tail.length, total: chain.length, receipts: tail }, }; } // --- GET /v1/ledger/{hash} ----------------------------------------------- if (method === "GET" && url.pathname.startsWith("/v1/ledger/")) { const hash = decodeURIComponent(url.pathname.slice("/v1/ledger/".length)); if (!hash) { return { status: 400, body: { error: "missing receipt identifier" } }; } const chain = readReceiptJsonl(config.ledgerPath); const match = chain.find( (r) => r.receipt_id === hash || r.merkle_root === hash, ); if (!match) { return { status: 404, body: { error: "receipt not found", hash } }; } return { status: 200, body: { receipt: match } }; } // --- POST /v1/verify ------------------------------------------------------ if (method === "POST" && url.pathname === "/v1/verify") { let parsed: unknown; try { parsed = JSON.parse(body || "{}"); } catch { return { status: 400, body: { error: "invalid JSON body" } }; } const ledger = (parsed as { ledger?: unknown }).ledger; if (!Array.isArray(ledger)) { return { status: 400, body: { error: "body must be {ledger: [...]}" } }; } const chain = ledger as OperationalReceipt[]; const result = verifyChain(chain); const brokenAt = brokenIndex(result.errors); return { status: 200, body: { valid: result.valid, broken_at: brokenAt, errors: result.errors }, }; } // --- GET /v1/policy/example ----------------------------------------------- if (method === "GET" && url.pathname === "/v1/policy/example") { return { status: 200, body: { description: "Sample valid request body for POST /v1/policy/evaluate", formats: [ { name: "flat (preferred)", body: { actionId: "example-action", severity: "medium", confidence: 0.9, witnesses: [ { id: "agent-a", role: "approver", attested: true }, { id: "agent-b", role: "reviewer", attested: true }, ], }, }, { name: "wrapped (legacy compatible)", body: { action: { actionId: "example-action", severity: "medium", confidence: 0.9, witnesses: [ { id: "agent-a", role: "approver", attested: true }, { id: "agent-b", role: "reviewer", attested: true }, ], }, }, }, ], severity_values: ["low", "medium", "high", "critical"], }, }; } // --- POST /v1/policy/evaluate -------------------------------------------- if (method === "POST" && url.pathname === "/v1/policy/evaluate") { let parsed: unknown; try { parsed = JSON.parse(body || "{}"); } catch { return { status: 400, body: { error: "invalid JSON body" } }; } // Accept either {action:{...}} (legacy) or flat {severity,...} (preferred). const p = parsed as { action?: unknown; severity?: unknown; actionId?: unknown }; const action = p.action && typeof p.action === "object" ? p.action : (p.severity !== undefined || p.actionId !== undefined) ? p // flat body — treat top-level as the action object : null; if (!action || typeof action !== "object") { return { status: 400, body: { error: "body must be {severity,...} or {action:{...}}", example: "/v1/policy/example" } }; } const a = action as { actionId?: string; severity?: string; decisionClass?: string; confidence?: number; witnesses?: unknown; }; try { const gate = thresholdPolicySeverityGate(); // Normalise witnesses: accept either full {id, role, attested} objects or // plain strings (coerced to {id: s, role: "witness", attested: true}). const rawWitnesses = Array.isArray(a.witnesses) ? a.witnesses : []; const normWitnesses = rawWitnesses.map((w: unknown) => typeof w === "string" ? { id: w, role: "witness", attested: true } : (w as Parameters[0]["witnesses"][number]), ); const decision = gate({ actionId: a.actionId ?? "unspecified-action", severity: (a.severity ?? "medium") as Parameters[0]["severity"], decisionClass: a.decisionClass as Parameters[0]["decisionClass"], confidence: typeof a.confidence === "number" ? a.confidence : 0, witnesses: normWitnesses, }); // The gate is a runtime severity-threshold gate (no Lean closure), so the // "gate" identifier reported is its formula name, not a Lean axiom index. // receipt_hash comes from the gate's own real HMAC-signed DSSE receipt // when allowed; on deny no signed receipt is minted, so we surface the // deterministic decision hash instead. const receiptHash = decision.dsseReceipt?.signatures[0]?.sig ?? ""; return { status: 200, body: { decision: decision.allow ? "allow" : "deny", gate: decision.formula, receipt_hash: receiptHash, rationale: decision.rationale, lambda_score: decision.lambdaScore, }, }; } catch (error) { return { status: 400, body: { error: (error as Error).message } }; } } return { status: 404, body: { error: "not found", path: url.pathname } }; } /** Map verifyChain's "position N: ..." error strings to the earliest broken index. */ export function brokenIndex(errors: readonly string[]): number | null { let min: number | null = null; for (const err of errors) { const m = /^position (\d+):/.exec(err); if (m) { const idx = Number.parseInt(m[1], 10); if (min === null || idx < min) min = idx; } } return min; } function readBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; let size = 0; const MAX = 8 * 1024 * 1024; // 8 MiB cap req.on("data", (chunk: Buffer) => { size += chunk.length; if (size > MAX) { reject(new Error("request body too large")); req.destroy(); return; } chunks.push(chunk); }); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } /** Create (but do not start) the HTTP server for the given config. */ export function createServer(config: ServeConfig): http.Server { return http.createServer(async (req, res) => { const send = (response: JsonResponse) => { const payload = JSON.stringify(response.body); res.writeHead(response.status, { "content-type": "application/json; charset=utf-8", "content-length": Buffer.byteLength(payload), }); res.end(payload); }; try { const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); const body = req.method === "POST" || req.method === "PUT" ? await readBody(req) : ""; send(handleRoute(req.method ?? "GET", url, body, config)); } catch (error) { send({ status: 500, body: { error: (error as Error).message } }); } }); } export function startServer(config: ServeConfig): Promise { const server = createServer(config); return new Promise((resolve) => { server.listen(config.port, config.host, () => { const addr = server.address(); const boundPort = typeof addr === "object" && addr ? addr.port : config.port; console.log( JSON.stringify({ msg: "a11oy serve listening", host: config.host, port: boundPort, ledger: config.ledgerPath, sha: config.sha, }), ); resolve(server); }); }); } function main(): void { const config = parseServeConfig(process.argv.slice(2)); void startServer(config); } // Run when invoked directly (node src/serve.ts), not when imported by tests. const invokedDirectly = process.argv[1] !== undefined && process.argv[1].endsWith("serve.ts"); if (invokedDirectly) { main(); }