The broker gains the reader verb `decisions` with `{list: 'roles' |
'recent'}`: the open decisions routed to each role, and the last 50
decisions (RECENT). views.mjs exposes it in the Q1 client module, and
webui serves `GET /api/bus/decisions?list=roles|recent`. Any other list
is 400 and never reaches the bus. No Inbox tab uses the route yet; the
tabs are the design rows' work. The bus and webui READMEs list the verb,
the route and who may call it.
Filbert built it (agents/filbert/work/queue-57). Round 1 approve from
Darkwing (27290) and Dewey (27300) on manifest af3b03cf, 8 paths. Their
non-blocking notes (no seen-then-resolve test, no webui test of ?list=
before the no-bus 503, test 4 compares fewer fields, roles has no LIMIT,
3 of 14 mutants survived) go to a follow-up row.
Sage's gate on 3c79809c, sequential and teed: node suites bus 80 and
webui 40, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed.
958668a8..3c79809c touches none of the 8 paths.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
153 lines
9.0 KiB
JavaScript
153 lines
9.0 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).
|
|
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs.
|
|
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];
|
|
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 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); });
|
|
});
|
|
}
|