Row 76 round 2, owner Dewey, candidate manifest 04ecf1b5 on 729da72b.
Carries decisions 99 and 100: Darkwing's and Filbert's round 1 notes,
the README proxy contract line, and a 408 counted as unknown.
Approved by Filbert (27685) and Darkwing (27688); Sage's gate green
03:25-03:29Z.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
233 lines
16 KiB
JavaScript
233 lines
16 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, sendOutcome } 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, 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.
|
|
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'];
|
|
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 (!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.
|
|
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) } = {}) {
|
|
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/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); });
|
|
});
|
|
}
|