webui: the Console sends a message to a role (#1570)

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]>
This commit is contained in:
2026-10-11 22:29:25 -05:00
co-authored by Claude Opus 5.5
parent be6a913b86
commit c45b948ded
9 changed files with 798 additions and 40 deletions
+18 -7
View File
@@ -2,9 +2,11 @@
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
// Only the four verbs of the Q1 module (lead decision 56), row 57's
// `decisions` and row 60's `credentials` reads are reachable: the WebUI
// writes nothing, not even seen (Q4). The bus does the proof; a server
// started inside an agent run gets `human-required` from the broker.
// `decisions` and row 60's `credentials` reads are reachable as reads: no
// read writes, not even seen (Q4). The one write is row 76's human
// `message.send` (lead decision 95), built by the CLI only with --send. The
// bus does the proof; a server started inside an agent run gets
// `human-required` from the broker.
import { spawn } from 'node:child_process';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
@@ -55,8 +57,8 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI
// The business is the --business flag, or else the live bus host's, read
// on every request so a host started after the Console is picked up.
export function busReader({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
const call = async (verb, args) => {
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
return async (verb, args) => {
let id = business;
if (!id) {
let state;
@@ -66,7 +68,16 @@ export function busReader({ dataRoot, socket, business = null, env = process.env
}
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
};
return views({ call });
}
export const busReader = options => views({ call: busCaller(options) });
// A send's error, sorted. The broker writes in one transaction, so any code it
// returns means nothing was written. A transport that lost the answer, or an
// answer that can't be read, leaves the outcome open: the message may exist.
const OPEN = ['outcome-unknown', 'invalid-response', 'response-too-large'];
export function sendOutcome(err) {
const code = err instanceof BusReadError ? err.code : 'outcome-unknown';
return OPEN.includes(code) ? { code: 'outcome-unknown', sent: 'unknown' } : { code, sent: false };
}
// HTTP status by bus error code. 403: the bus refused this caller; 503: no
@@ -74,6 +85,6 @@ export function busReader({ dataRoot, socket, business = null, env = process.env
const STATUS = {
'human-required': 403, unauthenticated: 403, 'unknown-business': 403, 'read-only': 403,
'outcome-unknown': 503, 'no-bus-host': 503, 'not-configured': 503,
'invalid-request': 400,
'invalid-request': 400, 'unknown-role': 400, 'message-not-found': 400, 'request-too-large': 400,
};
export const busStatus = code => STATUS[code] ?? 502;
+21 -8
View File
@@ -1,18 +1,23 @@
#!/usr/bin/env node
import { startServer, previewOptions, DEFAULT_BOARD } from './serve.mjs';
import { busReader } from './bus.mjs';
import { busCaller } from './bus.mjs';
import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {};
let sendOn = false;
while (args.length) {
const flag = args.shift(), value = args.shift();
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
@@ -29,21 +34,29 @@ try {
else throw new Error(`unknown option: ${flag}`);
}
options.preview = previewOptions(preview);
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
// The Queue, Business and Settings reads (row 54) need no bus; without
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
let bus = null, busLine, system = null;
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, busLine, system = null;
try {
system = loadSystem();
const { dataRoot } = system;
bus = busReader({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
const call = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
bus = views({ call });
if (sendOn) send = args => call('message.send', args);
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) { busLine = `Bus: not configured (${redactor({ configDir: configDir() })(err.message)})`; }
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview });
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : '';
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, send });
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${previewLine}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => server.close());
} catch (err) {
+7 -1
View File
@@ -372,7 +372,13 @@
// Do not discard text typed while the request was in flight.
if (result.delivered && drafts.get(id) === text) { drafts.delete(id); if (inView && box.value === text) box.value = ''; }
if (result.delivered && !paused) await refresh();
} catch (err) { receipts.set(id, { error: err.message + ' Delivery may be unknown; check the seat before sending again.' }); }
} catch (err) {
// A 4xx is a refusal before anything ran (the Console's, or the board's: see replyToRow), so
// nothing was sent. No answer, an unreadable one, a 5xx or a 408 (a proxy can answer it
// after forwarding) leaves delivery unknown.
const refused = err.status >= 400 && err.status < 500 && err.status !== 408;
receipts.set(id, { error: err.message + (refused ? '. Not sent.' : '. Delivery may be unknown; check the seat before sending again.') });
}
finally { sending.delete(id); render(); announce(receipts.get(id)?.delivered ? 'Reply delivered' : 'Reply failed; draft kept'); }
});
document.addEventListener('keydown', e => {
+86 -7
View File
@@ -1,7 +1,8 @@
// Slice 1 S5 (#1522): inbox, tasks, agents and trail, read through /api/bus (src/bus.mjs),
// which reaches only the four reads of the Q1 module (lead decision 56). Nothing on these
// pages writes: decisions are answered with `mosaic decide` in a terminal, and Copy only
// puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it
// pages writes but row 76's message to a role, shown only under `serve --send` behind the
// proxy (lead decision 95): decisions are answered with `mosaic decide` in a terminal, and
// Copy only puts that command on the clipboard (Q4). What no Q1 read returns is labelled where it
// would show (lead decision 63). The control board (app.js) is untouched and stays at #/.
(() => {
'use strict';
@@ -229,7 +230,7 @@
function sections(route) {
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && route.startsWith('/trail/decision'));
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
}).join('');
}
@@ -242,12 +243,48 @@
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
// Row 76 (#1570, lead decision 95): a human message to a held role, only when the server says
// a send can pass here (`send` in /api/config). The draft lives in `draft`, not the DOM, so the
// 10 s refresh redraws the form without losing it. Nothing resends: an unknown outcome stays
// unknown and the draft stays put.
let canSend = false, sendRoles = null, sending = false, receipt = null;
const draft = { to: '', body: '' };
function composerHtml() {
const roles = sendRoles ? [...new Set([...sendRoles, ...(draft.to && !sendRoles.includes(draft.to) ? [draft.to] : [])])] : null;
const options = roles ? roles.map(r => `<option value="${esc(r)}"${r === draft.to ? ' selected' : ''}>${txt(r)}${sendRoles.includes(r) ? '' : ' (not held now)'}</option>`).join('') : '';
const r = receipt;
const said = !r ? '' : r.kind === 'sent'
? `<p class="receipt">Sent to ${txt(r.to)} ${when(r.at)}, message <code>${txt(short(r.id))}</code>. <a href="#/trail/message/${enc(r.id)}">Its trail</a>.</p>`
: r.kind === 'refused'
? `<p class="receipt failed">Not sent to ${txt(r.to)}: ${txt(r.message)}${r.message.includes(`(${r.code})`) ? '' : ` (<code>${txt(r.code)}</code>)`}. The draft is kept.</p>`
: `<p class="receipt failed">Outcome unknown: the message to ${txt(r.to)} may have been sent. Nothing was sent again, and the draft is kept. The bus gave no message id, so there is no trail to link: before sending again, look for it on the role's side or in the bus trail from a terminal.</p>`;
return `<section class="panel s1-send" id="send-section" aria-labelledby="b-send"><h2 id="b-send">Message a role</h2>
<p class="small muted">Goes to the role as a message from the human, the same way as one sent from the CLI, with class INFO. A reply comes back to you as a message, which this Console does not show yet. The next <code>mosaic talk</code> in a terminal prints it, after sending its own message.</p>
${roles === null ? '<p class="small warn">The held roles could not be read, so there is nothing to pick. Read again to retry.</p>' : !roles.length ? '<p class="small muted">No role is held right now, so there is nothing to pick.</p>' : ''}
<form id="send-form" class="reply-form" novalidate><label for="send-to">To</label><select id="send-to" data-field="to"><option value="">Choose a held role</option>${options}</select>
<label for="send-body">Message</label><textarea id="send-body" data-field="body" rows="4" placeholder="What should the role do?">${esc(draft.body)}</textarea>
<button class="btn primary" type="submit" data-send aria-disabled="${sending}">${sending ? 'Sending…' : 'Send'}</button></form>${said}</section>`;
}
async function composer(get) {
if (!canSend) return '';
try { sendRoles = (await get('agents')).map(c => c.role).filter(r => typeof r === 'string' && SAFE.test(r)); } catch { sendRoles = null; }
return composerHtml();
}
// Redraw only the form, keeping focus, after a send changes its state.
function paintSend() {
const el = $('send-section');
if (!el) return;
const k = keep();
el.outerHTML = composerHtml();
restore(k);
}
async function inboxView(get) {
// The broker orders gated first, then oldest; lift blocking ones to the top.
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
${await composer(get)}
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
}
@@ -455,6 +492,11 @@
const row = (await get('tasks')).find(r => r.task_ref === ref);
if (!row) return notFound(`No task ${txt(ref)} in this business.`);
rows = lines(await get('trail', ref), row.seq);
} else if (kind === 'message') {
// Row 76: a sent message's trail, its request event and deliveries. Replies are not in it.
const tr = await get('trail', ref);
if (!of(tr, 'messages').some(m => m.id === ref)) return notFound(`No message with id ${txt(ref)}.`);
rows = lines(tr);
} else {
const d = await decision(get, ref);
if (!d) return notFound(`No decision with id ${txt(ref)}.`);
@@ -465,8 +507,8 @@
}
const filter = GROUPS.some(([g]) => g === q.get('kind')) ? q.get('kind') : 'all';
const base = `#/trail/${kind}/${enc(ref)}`;
const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for decision ${txt(short(ref))}`;
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
const name = kind === 'task' ? `Trail for task #${esc(String(ref).split('/')[1])}` : `Trail for ${kind} ${txt(short(ref))}`;
const back = kind === 'task' ? `<a href="#/tasks/${enc(ref)}">Back to the task</a>` : kind === 'message' ? '<a href="#/inbox">Back to the Inbox</a>' : `<a href="#/inbox/${enc(ref)}">Back to the decision</a>`;
return `${head(name, `${readNote()} Oldest first. ${back}`)}
<nav aria-label="Filter the trail" class="chips">${GROUPS.map(([g, l]) => `<a class="chip" href="${base}${g === 'all' ? '' : `?kind=${g}`}"${filter === g ? ' aria-current="true"' : ''}>${l} <span class="count">${g === 'all' ? rows.length : rows.filter(r => r.group === g).length}</span></a>`).join('')}</nav>
${trailList(rows, filter)}`;
@@ -538,24 +580,30 @@
if (a === 'settings' && n === 1) return settingsView(get);
if (a === 'trail' && n === 3 && b === 'task') return TASK.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a task reference.`);
if (a === 'trail' && n === 3 && b === 'decision') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a decision id.`);
if (a === 'trail' && n === 3 && b === 'message') return SAFE.test(c) ? trailView(get, b, c, q) : notFound(`${txt(c)} is not a message id.`);
return notFound();
}
let gen = 0, shown = null, timer;
const paused = () => $('pause')?.getAttribute('aria-pressed') === 'true';
function schedule() { clearTimeout(timer); if (!paused()) timer = setTimeout(() => render(false), 10000); }
// Keep focus, open <details> and the copy status across a refresh of the same page.
// In the send form, the focused field and its caret too (its values come from `draft`).
function keep() {
const a = document.activeElement, view = $('bus-view');
return {
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
};
}
function restore(k) {
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
if ($('copy-status')) $('copy-status').textContent = k.status;
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.retry && e.hasAttribute('data-retry'));
if (el && (k.href || k.copy || k.retry || k.mirror)) el.focus({ preventScroll: true });
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry'));
if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
el.focus({ preventScroll: true });
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
}
else if (k.h1) $('bus-view').querySelector('h1')?.focus({ preventScroll: true });
}
async function render(navigated) {
@@ -688,6 +736,37 @@
if (m) { const src = $(m.dataset.mirror); src.value = m.value; src.dispatchEvent(new Event('change')); return; }
if (e.target.id === 'palette' || e.target.id === 'mode') for (const s of document.querySelectorAll(`#bus-view select[data-mirror="${e.target.id}"]`)) s.value = e.target.value;
});
// The send form (row 76). Values go to `draft` as typed; Send posts once and never again by itself.
document.addEventListener('input', e => { const f = e.target.closest?.('#send-form [data-field]'); if (f) draft[f.dataset.field] = f.value; });
document.addEventListener('submit', async e => {
if (e.target.id !== 'send-form') return;
e.preventDefault();
if (sending) return;
const to = draft.to, text = draft.body;
if (!to || !text.trim()) { receipt = { kind: 'refused', to: to || 'no role', code: 'invalid-request', message: to ? 'the message is empty' : 'choose a role first' }; paintSend(); announce('Not sent: ' + receipt.message); return; }
sending = true; receipt = null; paintSend();
let res = null, body = null;
try {
res = await fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });
try { body = await res.json(); } catch { body = null; }
} catch { res = null; }
// Sent: an id came back. Not sent: a 4xx (refused before the bus wrote), the server's
// sent:false, or a redirect, which only the sign-in proxy gives (an expired session), so the
// request never reached the Console. Anything else, no answer included, may have been sent,
// and so may a 408: a proxy can answer it after forwarding (Envoy's stream_idle_timeout).
if (res?.type === 'opaqueredirect') {
receipt = { kind: 'refused', to, code: 'sign-in', message: 'the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again' };
} else if (res?.ok && SAFE.test(body?.id ?? '')) {
receipt = { kind: 'sent', to, id: body.id, at: body.at };
if (draft.body === text) draft.body = ''; // keep text typed while the send was in flight
} else if (res && !res.ok && ((res.status >= 400 && res.status < 500 && res.status !== 408) || body?.sent === false)) {
receipt = { kind: 'refused', to, code: typeof body?.error === 'string' ? body.error : `HTTP ${res.status}`, message: typeof body?.message === 'string' ? body.message.replace(/^not sent: /, '') : 'refused' };
} else receipt = { kind: 'unknown', to };
sending = false; paintSend();
announce(receipt.kind === 'sent' ? `Sent to ${to}` : receipt.kind === 'refused' ? 'Not sent; draft kept' : 'Outcome unknown; it may have been sent; draft kept');
});
// Whether this server takes a send here. The form is drawn on the next Inbox render.
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(c => { if (c?.send === true) { canSend = true; if (routeOf().startsWith('/inbox')) render(false); } }).catch(() => {});
window.addEventListener('hashchange', () => render(true));
$('refresh').addEventListener('click', () => render(false));
$('pause').addEventListener('click', () => setTimeout(schedule)); // after app.js flips aria-pressed
+47 -6
View File
@@ -2,7 +2,7 @@ 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 { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs';
import { ReadError, readStatus } from './reads.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
@@ -31,13 +31,13 @@ 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) {
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 > 4096) throw new Error('body larger than 4096 bytes');
if (size > max) throw new Error(`body larger than ${max} bytes`);
chunks.push(chunk);
}
const bytes = Buffer.concat(chunks);
@@ -70,6 +70,38 @@ async function busRead(res, bus, verb, search) {
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}$/;
@@ -96,7 +128,9 @@ export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
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 };
}
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null } = {}) {
// `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);
@@ -104,6 +138,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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');
@@ -131,7 +166,12 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
// Nothing but GET and HEAD gets past here in preview mode, whatever the Host.
// 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';
@@ -173,7 +213,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
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 });
// `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);