Files
stack/packages/webui/src/serve.mjs
T
jason.woltjeandClaude Opus 5.5 cbd79cf666 feat(webui): read-only Queue, Business and Settings views (#1543, row 54)
The Console gains three read-only views. Queue lists every row from
rows() in packages/queue (lead decision 83: the same lock-free read as
`queue list`, writing nothing), run in a child with a timeout and an
output cap; a row page shows the full row. Business shows names, an
allowlist of vars, arbiters, authority per action and credential
metadata (service, account, role, date, never a value, file or
variable). Settings shows RELEASE, the board origin and the notifier
binding. Every route is GET only, and every string in a body or
refusal passes a redactor: the config directory and dataRoot become
<config> and <dataRoot>, other absolute, ~/ and file:// paths <path>,
and 17 to 20 digit runs <id>. A queue-read that fails to start sends a
fixed message, never node's stderr.

Four fixes to HEAD behaviour, each with a test: the bus view's refresh
timer is cleared at render, a same-page refresh keeps focus on the H1,
#conv-pick is emptied when a conversation opens, and error() returns
focus to <main> only when something had focus. Filbert's T8 tests the
failed first read.

Dewey built it in two rounds. Round 1 (dba2429e) got changes from
Filbert (27185: After rendered [object Object], ~/ and :/ paths leaked)
and Darkwing (27186: Arbiters always none, queue-read import failure
leaked a file:// path and stack). Round 2 (afb2ae0e) was approved by
Filbert (27190) and Darkwing (27191, correction 27192). Sage's gate on
d64f434f plus the candidate: 13 package node suites 0 failed (queue
149, webui 39), every scripts/test-*.sh 0 failed (task 98/0 with
Docker, 26/0 without; release 14/0), build-tokens --check current.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:11:17 -05:00

147 lines
8.6 KiB
JavaScript

import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus, subjectOk } from './bus.mjs';
import { ReadError, readStatus } from './reads.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
}
export function boardURL(value) {
const url = new URL(value);
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
}
// Avoid hostname resolution for localhost.
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
return url.origin;
}
const root = resolve(import.meta.dirname, 'public');
const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/icons.svg', ['icons.svg', 'image/svg+xml']],
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
]);
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
}
async function body(req) {
if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new Error('Content-Type must be application/json');
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > 4096) throw new Error('body larger than 4096 bytes');
chunks.push(chunk);
}
const bytes = Buffer.concat(chunks);
const value = JSON.parse(bytes.toString('utf8'));
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
return bytes;
}
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail'];
async function busRead(res, bus, verb, search) {
let subject;
if (verb === 'trail') {
subject = new URLSearchParams(search).get('subject');
if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
}
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const rows = await bus[verb](subject);
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
return json(res, 200, { rows, at: new Date().toISOString() });
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// Row 54 (#1543): GET only. `reads` is consoleReads() from reads.mjs; its
// bodies and messages arrive redacted. No route here writes or calls out.
const QUEUE_ID = /^[1-9]\d{0,5}$/;
async function consoleRead(res, read) {
try { return json(res, 200, await read()); } catch (err) {
const code = err instanceof ReadError ? err.code : 'invalid-response';
return json(res, readStatus(code), { error: code, message: err instanceof ReadError ? err.message : code });
}
}
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
const upstream = boardURL(board);
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
let path, search;
try {
const authority = new URL(`http://${req.headers.host}`);
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' });
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
} catch { return json(res, 400, { error: 'invalid URL' }); }
// No CORS. JSON content type and same-origin checks keep browser forms out.
if (req.headers.origin && req.headers.origin !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' });
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
if (allowed) {
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
let bytes;
if (allowed === 'POST') {
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
}
try {
const response = await fetch(upstream + path + (conversation ? search : ''), {
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
redirect: 'error', signal: AbortSignal.timeout(timeout),
});
const text = await response.text();
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
return res.end(text);
} catch {
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
}
}
if (path.startsWith('/api/bus/')) {
const verb = path.slice('/api/bus/'.length);
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return busRead(res, bus, verb, search);
}
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
if (path === '/api/business') return consoleRead(res, () => reads.business());
if (path === '/api/queue') return consoleRead(res, () => reads.queue());
const id = path.slice('/api/queue/'.length);
if (!QUEUE_ID.test(id)) return json(res, 400, { error: 'invalid-request', message: 'a queue row id is a positive integer' });
return consoleRead(res, () => reads.row(Number(id)));
}
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
if (path === '/api/config') return json(res, 200, { board: upstream });
if (path === '/healthz') return json(res, 200, { ok: true });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
if (!file) return json(res, 404, { error: 'not found' });
try {
const content = readFileSync(resolve(root, file[0]));
res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-store' });
res.end(req.method === 'HEAD' ? undefined : content);
} catch { json(res, 500, { error: 'WebUI asset unavailable' }); }
});
return new Promise((resolvePromise, reject) => {
server.once('error', reject);
server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); });
});
}