Files
stack/packages/webui/src/serve.mjs
T
jason.woltjeandClaude Opus 5.5 2b2a9e14ee bus, cli, webui: every message to the human, read state, Messages page (#1588)
Row 94, round 2. Rocko built it, Dewey approved it (comment 28104) on
manifest 53b01ea9 (16 paths, base e918a915). The bus keeps a messages
view with read state, where delivered but not read stays read:false. The
CLI gains messages and follow, and inbox leaves out messages already
read while inbox --json stays byte-compatible. The Console gets a
Messages page and an unread count in the nav. Mos gave the go under
decision 123 (public/ files). Sage gate on 645489e4 plus the patch,
12:52:14Z to 12:55:44Z: bus 115, cli 129, webui 104, all fail 0. Every
scripts/test-*.sh exited 0 (auth 15, conductor 17, config 24, discord
66, extension-package 18, foundation 44, queue 27, release 14, task 98).
Manifest 16/16 before and after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-13 07:58:36 -05:00

262 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isIP } from 'node:net';
import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs';
import { ReadError, readStatus, fieldTable } 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, max = 4096) {
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 > max) throw new Error(`body larger than ${max} 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).
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
// (#1575) adds `replies`: the messages answering one message, for the send receipt.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages'];
const DECISION_LISTS = ['roles', 'recent'];
async function busRead(res, bus, verb, search) {
let arg;
if (verb === 'trail') {
arg = new URLSearchParams(search).get('subject');
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
}
if (verb === 'decisions') {
arg = new URLSearchParams(search).get('list');
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
}
if (verb === 'replies') {
arg = new URLSearchParams(search).get('id');
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
}
if (verb === 'messages') {
const query = new URLSearchParams(search), args = {};
const invalid = [...query.keys()].some(k => !['limit', 'after'].includes(k) || query.getAll(k).length !== 1);
if (invalid || (query.has('limit') && (!/^[1-9]\d*$/.test(query.get('limit')) || Number(query.get('limit')) > 200)) ||
(query.has('after') && !subjectOk(query.get('after'))))
return json(res, 400, { error: 'invalid-request', message: 'messages takes limit (1–200) and an after message id' });
if (query.has('limit')) args.limit = Number(query.get('limit'));
if (query.has('after')) args.after = query.get('after');
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const page = await bus.messages(args);
if (!page || !Array.isArray(page.messages)) throw new BusReadError('invalid-response');
const { messages: rows, ...metadata } = page;
return json(res, 200, { rows, ...metadata, 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 });
}
}
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try {
const rows = await bus[verb](arg);
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 76 (#1570, lead decision 95): with --send, the one POST preview mode allows, a human
// message to a role. Checked here again whatever passed above: the header on this request, its own
// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB, and only these keys. `to` and the body are the
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
async function busSend(req, res, { send, preview, log }) {
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
let to = null;
// `to` is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !SEND_KEYS.includes(k)) || !('to' in value) || !('body' in value)) return refuse(400, 'invalid-request', 'a send takes to, body and an optional in_reply_to, nothing else');
({ to } = value);
const args = { to: value.to, body: value.body };
if ('in_reply_to' in value) args.in_reply_to = value.in_reply_to;
let result;
try { result = await send(args); } catch (err) {
const { code, sent } = sendOutcome(err);
return sent === false
? refuse(busStatus(code), code, `not sent: the bus refused it (${code})`)
: refuse(503, 'outcome-unknown', 'the message may have been sent; nothing was retried. Check the trail before sending again', 'unknown');
}
if (!subjectOk(result?.id)) return refuse(503, 'outcome-unknown', 'the bus answered without a message id; the message may have been sent. Check the trail before sending again', 'unknown');
line(`id=${result.id}`);
return json(res, 200, { id: result.id, at: new Date().toISOString() });
}
// 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 });
}
}
// Row 74 (#1568, lead decision 93): the preview mode behind the edge proxy. Off unless
// all three are given. The bind stays loopback; the proxy signs the user in
// and sets the header; the Console checks it against the one user id and serves reads only.
// Nothing here names an identity provider: one header name and one user id, from the flags.
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/;
const USER = /^[\x21-\x2b\x2d-\x7e]{1,256}$/;
export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
const given = [publicHost, authHeader, allowUser].filter(v => v !== undefined && v !== null);
if (!given.length) return null;
if (given.length !== 3) throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
const host = String(publicHost).toLowerCase(), header = String(authHeader).toLowerCase();
if (!HOSTNAME.test(host) || isIP(host)) throw new Error('public host must be a DNS name without scheme or port');
if (!HEADER.test(header) || ['host', 'origin'].includes(header)) throw new Error('auth header must be a header name other than Host or Origin');
if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma');
return { host, header, user: allowUser };
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
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);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
}
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
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;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
// HEADER with any other Host comes from a proxy that rewrote Host (nginx's default) and would
// otherwise land on the loopback path unchecked. The operator's browser never sends it.
if (preview !== null && !named && req.headersDistinct[preview.header]) return json(res, 403, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' });
try {
if (!named) {
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 !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
if (named) {
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
// With --send, POST /api/bus/message is the one exception; it checks the header again itself.
if (send && path === '/api/bus/message') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview, log });
}
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
// 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/task-fields') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return consoleRead(res, () => fieldTable(fieldsFile));
}
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' });
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
if (path === '/api/config') return json(res, 200, { board: upstream, send: Boolean(send) && named });
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); });
});
}