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
+114 -9
View File
@@ -23,7 +23,7 @@ Only HTTP loopback board origins are accepted. The WebUI binds to IPv4 loopback
by default, rejects a repeated Host (400), nonlocal Host and cross-origin
requests, sends no CORS
headers, refuses redirects and accepts only JSON object POST bodies up to 4096
bytes. Do not put the board behind a public proxy. Put the Console behind one
bytes (16 KiB for row 76's send, below). Do not put the board behind a public proxy. Put the Console behind one
only in the preview mode below, with a proxy that meets its contract.
## Preview behind a sign-in proxy (row 74, #1568)
@@ -53,14 +53,16 @@ In preview mode:
operator's browser on the host still works. Any other Host is 403.
- A request that carries HEADER with any Host but NAME answers 403 `proxy
Host refused`. The operator's browser never sends HEADER; a proxy that
rewrote Host does, and it fails on its first request instead of reaching
rewrote Host does, and it fails on its first signed-in request instead of reaching
the loopback path unchecked.
- More than one Host header answers 400 before any other check, in this
mode or not. Node keeps the first, so a loopback Host followed by
`Host: NAME` would otherwise skip the sign-in check.
- Every method other than GET and HEAD answers 403 `read-only`, whatever the
Host. `/api/seen` and `/api/reply` never reach the board, so Seen and Reply
fail in the page with that refusal.
fail in the page with that refusal. The one exception is POST
`/api/bus/message` when the Console was started with `--send` (next
section).
- The GET routes are unchanged: the bus views, the Queue, Business and
Settings reads, the board panel and the assets. If the board is down, its
panel shows the existing error and the bus views still answer.
@@ -80,14 +82,117 @@ controls it:
operator's path and skips the header check, so a proxy that forwarded a
client's `Host: 127.0.0.1:7332` would let anyone through without signing
in. A tunnel whose local end uses the same port number sends such a Host
and reaches the Console unchecked.
and reaches the Console unchecked. Anything that reaches a tunnel's local
end can send a loopback Host without HEADER, so bind that end to loopback.
- Every hop on this host that reaches the loopback port is bound by this
contract too, not only the edge.
- It passes the Console's status and body through and doesn't rewrite
upstream errors (nginx's `proxy_intercept_errors` with an `error_page`,
for example). Under `--send` the page reads a 4xx other than 408 as "not
sent", so a proxy that turned the Console's 503 `outcome-unknown` into a
4xx or a redirect would report a message that may exist as not sent.
The local exposure doesn't change ("Data and boundaries"): any process on
the host can still connect to the port and send `Host: NAME` with HEADER and
ID, or just a loopback Host, and read what a reader capability reads. The
user id is not a secret; the sign-in at the edge is what keeps others out.
Under `--send` the first of those can also send (next section).
To run the Console as a systemd user unit, `scripts/webui-service.sh`
renders and installs one with these flags (row 75; `docs/TOOLS.md`
"Console service"). Jason starts it from his own shell (lead decision 94).
The script takes no `--send`, so a unit it installs is read-only.
## Message a role (row 76, #1570)
```sh
node packages/webui/src/cli.mjs serve --port 7332 \
--public-host NAME --auth-header HEADER --allow-user ID --send
```
`--send` is off by default. It goes only with the three preview flags and
refuses at start without them. It also needs the system config: without it
the start refuses with `--send needs the bus configured`, where a read-only
Console would still serve the board. With `--send` the Inbox has a "Message
a role" box: a role picker filled from the agents read, a text box and
Send. The message reaches the role as a message from the human, class INFO,
through `message.send` on the human transport. It goes the same way as one
sent from the CLI, with class INFO where `mosaic talk` uses REQUEST (lead
decision 95). Decisions are still answered in a
terminal. There are no other message classes, no corrections and no seat
conversations here.
What `--send` grants: two kinds of client can send a human instruction to
any role while it runs.
- Anyone the proxy lets through as ID.
- Any local process that reaches the port and sets HEADER to ID with
`Host: NAME`. The user id is not a secret. This is the same class of
exposure as the human CLI (lead decision 62), accepted for the PoC by
lead decision 95.
The request is POST `/api/bus/message` with a JSON object `{to, body,
in_reply_to?}`. Nothing is sent unless all of these hold, each checked on
every request:
- Host is NAME and HEADER is ID, once: otherwise 401 `sign-in required`.
There is no loopback path. The operator's browser on `127.0.0.1` reads
but can't send, and a loopback Host with HEADER is 403 `proxy Host
refused`, as above.
- Origin is exactly `https://NAME`, else 403 `cross-origin request refused`.
- `Sec-Fetch-Site` is sent once and is exactly `same-origin`, else 403
`cross-site request refused`. Browsers set it, and page script can't.
- Content-Type is `application/json` and the body is at most 16384 bytes
and a JSON object with `to` and `body`, optionally `in_reply_to`, and no
other key. Anything else is 400 `invalid-request`.
The Console doesn't judge `to` or the body beyond that; the broker does,
with `message.send`'s own rules. Its body limit, 32768 characters, is past
the request limit, so the 16 KiB limit binds first. A broker refusal means
nothing was written. It answers `{ error, message, sent: false }` with the
read routes' statuses: 400 for `unknown-role`, `invalid-request`,
`message-not-found` and `request-too-large`, 403 for `human-required` and
`read-only`, 503 for `not-configured` and `no-bus-host`, 502 otherwise.
When the transport can't say what happened (a timeout, a child that exits
without a code, an answer that can't be read or that has no message id), the
message may have been written. The answer is 503 `outcome-unknown` with
`sent: "unknown"`. Nothing retries, in the server or in the page.
Each send that gets past the sign-in check writes one line to the Console's
stdout: the time, `send`, the user, the `to` value (JSON-quoted, at most 64
characters), then `id=<message id>` or `error=<code>`. The body is never
logged. A request refused at sign-in or by the shared Host and Origin
checks writes no line.
On the page:
- The draft, role and text, survives the ten-second refresh, kept in page
memory like a Reply draft. Reloading the page loses it. Send is disabled
while a send is pending.
- Sent shows the message id and links its trail, `#/trail/message/<id>`:
the message and its deliveries. A reply is not in it. The text is cleared
unless it was edited during the send.
- Not sent shows the broker's code and keeps the draft. A redirect counts
as not sent: the Console never redirects this route, so only the sign-in
proxy does, when its session has expired. The page asks for a reload to
sign in again.
- Outcome unknown says the message may have been sent, keeps the draft, and
has no trail to link, since no id came back. Check the role's side, or
the bus trail from a terminal, before sending again. A 408 counts as
unknown, here and on the board's Reply, since a proxy can answer it
after forwarding the request (Envoy's `stream_idle_timeout`).
The role's reply comes back as a message to the human. The Console doesn't
show it yet; that view is row 81 (#1575). Jason's next `mosaic talk` in a
terminal prints it, within talk's limits:
- talk receives only after it sends its own message;
- `--wait 0` returns straight after the send and receives nothing;
- otherwise each receive returns every undelivered message to the human, up
to 100 a call. The broker records each one delivered when
`message.receive` returns it, so the next receive doesn't return it
again; talk prints each one and marks it read.
## Use
@@ -133,14 +238,14 @@ delivery may be unknown: inspect the seat before sending again.
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>` and
`#/trail/decision/<id>`, with `?kind=` filters on a trail. The design
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>`,
`#/trail/decision/<id>` and `#/trail/message/<id>` (row 76), with `?kind=` filters on a trail. The design
note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
- Every view reads the four verbs of the Q1 module (`packages/bus/src/views.mjs`,
lead decision 56): `inbox`, `tasks`, `agents` and `trail`. The CLI reads the
same functions, so both show the same broker data.
- Nothing writes. A decision shows `mosaic decide <id> <key>` for each choice,
- Nothing writes but the "Message a role" box under `--send` (above). A decision shows `mosaic decide <id> <key>` for each choice,
with Copy, which only puts the command on the clipboard. It is answered in a
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
command has no `--business`, so `mosaic decide` uses the running bus host's
@@ -293,8 +398,8 @@ capability reads ("Human and reader paths" in `packages/bus/README.md`):
the inbox, tasks, agents, trails, the two decision lists and the credential
status. That includes
a T3 seat using `curl` and a managed S6 session, since S6 doesn't confine
the network. No route
writes (Q4), so the exposure is reads only. Slice 1 doesn't change this
the network. Without `--send` no route
writes (Q4), so the exposure is reads only; with it, see "Message a role". Slice 1 doesn't change this
(Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }`
with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer
that can't be used. The bus needs the system config
+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);
+195
View File
@@ -0,0 +1,195 @@
// Row 76 (#1570): the Inbox's "Message a role" box in a browser. A send passes only with the
// proxy's https Origin, which a loopback page can't have, so a script added before the page
// loads answers /api/config with send on and answers each POST /api/bus/message from a queue,
// recording what the page sent. A role's run is ended in the broker to show a role no longer held. Server-side checks are send.test.mjs's. The roles and the
// message trail come from an in-process broker through its reader session. The same script
// answers the board's /api/reply from its own queue while one is queued, for the Reply receipt.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
const businesses = {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' },
roles: {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: [] } },
},
},
};
// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect`
// what fetch gives for the sign-in proxy's redirect under redirect: 'manual' (type opaqueredirect, status 0).
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__sends = []; window.__answers = []; window.__replies = [];
window.fetch = async (url, init = {}) => {
const u = String(url);
if (u === '/api/reply' && window.__replies.length) {
const a = window.__replies.shift();
return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
}
if (u === '/api/config' && !location.search.includes('nosend')) {
const c = await (await real(url, init)).json();
return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } });
}
if (u !== '/api/bus/message') return real(url, init);
window.__sends.push({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
if (a.hold) await new Promise(r => { window.__release = r; });
if (a.reject) throw new TypeError('Failed to fetch');
if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } };
return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } });
};
})();`;
test('the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx', { timeout: 120000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-send-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
for (const role of ['pm', 'coder']) b0.request(b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' });
const human = b0.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const seeded = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Seeded message from the human' } });
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser();
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? null`);
const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`);
const answer = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
const send = async () => { await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"'); };
const sends = () => b.evaluate('window.__sends');
const receipt = () => text('#send-section .receipt');
const msg = 'Please look at row 76.';
try {
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
await b.viewport(1280);
// The board's Reply: a 4xx was refused before anything ran, so "Not sent."; a 5xx or a 408
// leaves delivery unknown. None reaches the board, and the draft stays.
await b.navigate(base);
await wait('document.querySelector("table.sessions [data-open=\'proj/agent1\']")');
await b.evaluate('document.querySelector("table.sessions [data-open=\'proj/agent1\']").click(), true');
await wait('document.querySelector("#reply")');
await b.evaluate('document.querySelector("#reply").focus(), true');
await b.call('Input.insertText', { text: 'reply draft' });
for (const [status, error, said] of [[403, 'refused here', 'failed: refused here. Not sent.'], [502, 'board unreachable', 'failed: board unreachable. Delivery may be unknown; check the seat before sending again.'], [408, 'request timeout', 'failed: request timeout. Delivery may be unknown; check the seat before sending again.']]) {
await b.evaluate(`window.__replies.push(${JSON.stringify({ status, body: { error } })}), true`);
await b.evaluate('document.querySelector(".reply-form").requestSubmit(), true');
await wait(`document.querySelector("#inspection .receipt")?.textContent === ${JSON.stringify(said)}`);
assert.equal(await value('#reply'), 'reply draft');
}
assert.equal(f.captures.length, 0);
// Without send in /api/config there is no box.
await b.navigate(`${base}?nosend#/inbox`);
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && !document.querySelector("#bus-view [aria-busy]")');
assert.equal(await b.evaluate('!!document.querySelector("#send-section")'), false);
await b.navigate(`${base}#/inbox`);
await wait('document.querySelector("#send-form")');
assert.equal(await text('#send-section h2'), 'Message a role');
assert.match(await text('#send-section'), /does not show yet\. The next mosaic talk in a terminal prints it, after sending its own message\./);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent])'), [['', 'Choose a held role'], ['coder', 'coder'], ['pm', 'pm']]);
// Nothing to send is refused in the page, without a request.
await send();
assert.match(await receipt(), /^Not sent to no role: choose a role first/);
await set('#send-to', 'coder');
await send();
assert.match(await receipt(), /^Not sent to coder: the message is empty/);
assert.deepEqual(await sends(), []);
// The draft, focus and caret survive a refresh, which redraws the form.
await set('#send-body', msg);
await b.evaluate('(()=>{const e=document.querySelector("#send-body");e.dataset.before="1";e.focus();e.setSelectionRange(3,7);document.querySelector("#refresh").click();return true})()');
await wait('!document.querySelector("#send-body")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', msg]);
assert.deepEqual(await b.evaluate('(()=>{const e=document.querySelector("#send-body");return {focus:document.activeElement===e,start:e.selectionStart,end:e.selectionEnd}})()'), { focus: true, start: 3, end: 7 });
// A role that stops being held stays selected, marked, so the draft isn't moved to another role.
await set('#send-to', 'pm');
b0.endLaunch({ business: 'demo', run: 'pm-run', reason: 'exited' });
await b.evaluate('(()=>{document.querySelector("#send-body").dataset.before="1";document.querySelector("#refresh").click();return true})()');
await wait('!document.querySelector("#send-body")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent, o.selected])'), [['', 'Choose a held role', false], ['coder', 'coder', false], ['pm', 'pm (not held now)', true]]);
await set('#send-to', 'coder');
// A refusal keeps the draft and says why.
await answer({ status: 400, body: { error: 'unknown-role', message: 'not sent: the bus refused it (unknown-role)', sent: false } });
await send();
assert.equal(await receipt(), 'Not sent to coder: the bus refused it (unknown-role). The draft is kept.');
assert.equal(await value('#send-body'), msg);
const [first] = await sends();
assert.deepEqual(first, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to: 'coder', body: msg }), redirect: 'manual' });
// A 503 the server marked not sent is a refusal too; an unknown outcome and a lost answer are unknown.
await answer({ status: 503, body: { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false } });
await send();
assert.equal(await receipt(), 'Not sent to coder: the bus refused it (no-bus-host). The draft is kept.');
// A redirect is the sign-in proxy's (the Console never redirects this route): not sent.
await answer({ redirect: true });
await send();
assert.equal(await receipt(), 'Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again (sign-in). The draft is kept.');
assert.equal(await value('#send-body'), msg);
// A 408 too: a proxy can answer it after forwarding.
for (const a of [{ status: 503, body: { error: 'outcome-unknown', message: 'the message may have been sent', sent: 'unknown' } }, { reject: true }, { status: 200, body: { at: 'x' } }, { status: 408, body: { error: 'request timeout' } }]) {
await answer(a);
await send();
assert.match(await receipt(), /^Outcome unknown: the message to coder may have been sent\. Nothing was sent again, and the draft is kept\./, JSON.stringify(a));
assert.equal(await value('#send-body'), msg);
assert.equal(await b.evaluate('!!document.querySelector("#send-section .receipt a")'), false);
}
// Each Send was one POST; none was repeated after the unknown ones.
await new Promise(r => setTimeout(r, 500));
assert.equal((await sends()).length, 7);
// While a send is pending, Send is disabled and a second press sends nothing; text typed
// meanwhile is kept when the first one succeeds.
await answer({ hold: true, status: 200, body: { id: seeded.id, at: new Date().toISOString() } });
await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
await wait('document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "true"');
assert.equal(await text('#send-form [data-send]'), 'Sending…');
await b.evaluate('document.querySelector("#send-form [data-send]").click(), true');
await set('#send-body', 'A second thought');
assert.equal((await sends()).length, 8);
await b.evaluate('window.__release(), true');
await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"');
assert.match(await receipt(), new RegExp(`^Sent to coder .*, message ${seeded.id.slice(0, 8)}\\. Its trail\\.$`));
assert.equal(await value('#send-body'), 'A second thought');
assert.equal((await sends()).length, 8);
// A plain success clears the text and keeps the role.
await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } });
await send();
assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', '']);
assert.equal(JSON.parse((await sends())[8].body).body, 'A second thought');
// The receipt links the message's trail, which the bus reads.
assert.equal(await b.evaluate('document.querySelector("#send-section .receipt a").getAttribute("href")'), `#/trail/message/${seeded.id}`);
await b.evaluate('document.querySelector("#send-section .receipt a").click(), true');
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Trail for message") && !document.querySelector("#bus-view [aria-busy]")');
assert.equal(await text('#bus-view h1'), `Trail for message ${seeded.id.slice(0, 8)}`);
assert.match(await text('#bus-view'), /Seeded message from the human/);
assert.equal(await b.evaluate('document.querySelector("#bus-view a[href=\'#/inbox\']")?.textContent'), 'Back to the Inbox');
assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/inbox');
await b.evaluate(`location.hash = '#/trail/message/no-such-message', true`);
await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"');
assert.match(await text('#bus-view'), /No message with id no-such-message\./);
assert.equal((await sends()).length, 9);
} finally {
await b.close(); await close(web); await f.close(); store.close();
rmSync(root, { recursive: true, force: true });
}
});
+305
View File
@@ -0,0 +1,305 @@
// Row 76 (#1570, lead decision 95): `serve --send`, a human message to a role from the Console.
// Only with the preview flags; POST /api/bus/message needs the header on this request, its own
// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB and only to, body and in_reply_to. The broker
// refuses `to` and the body. An outcome the transport can't confirm is unknown and never retried.
// Every other write is still 403 read-only. Nothing here starts the real human transport.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { BusReadError, humanCall } from '../src/bus.mjs';
import { close } from './fixture.mjs';
import { broker, fixture, tmp } from '../../cli/tests/helpers.mjs';
const NAME = 'mosaic-preview.example.com', HEADER = 'remote-user', ID = 'jason';
const preview = { host: NAME, header: HEADER, user: ID };
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const ok = { [HEADER]: ID, origin: `https://${NAME}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const without = name => Object.fromEntries(Object.entries(ok).filter(([k]) => k !== name));
const msg = JSON.stringify({ to: 'coder', body: 'Please look at row 76.' });
// node:http, not fetch: fetch can't set Host or Sec-Fetch-Site.
function post(port, { method = 'POST', path = '/api/bus/message', headers = ok, body = msg } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host: NAME, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
req.end(body);
});
}
// A board that counts what reaches it, a bus that counts its reads, and a send that records
// each call and answers from `answer`.
async function setup(t, options = {}) {
const boardHits = [];
const upstream = createServer((req, res) => { boardHits.push(`${req.method} ${req.url}`); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"ok":true}'); });
await new Promise(r => upstream.listen(0, '127.0.0.1', r));
t.after(() => close(upstream));
const busCalls = [], sends = [], lines = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async arg => { busCalls.push([v, arg]); return [{ verb: v }]; }]));
const state = { answer: async () => ({ id: 'msg-0001' }) };
const send = async args => { sends.push(args); return state.answer(args); };
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, bus, preview, send, log: l => lines.push(l), ...options });
t.after(() => close(web));
return { port: web.address().port, boardHits, busCalls, sends, lines, state };
}
test('startServer takes send only as a function and only with the preview mode', async () => {
for (const options of [{ send: async () => ({}) }, { send: async () => ({}), preview: null }, { send: 'yes', preview }, { send: {}, preview }]) {
const result = await startServer({ port: 0, ...options }).catch(err => err);
if (!(result instanceof Error)) { await close(result); assert.fail(`served with ${Object.keys(options)}`); }
assert.match(result.message, /--send needs the preview mode/);
}
});
test('the CLI refuses --send without the preview flags, and without a configured bus', t => {
const root = tmp(t, 'webui-send-cli-');
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'no-such-config.json') };
for (const [flags, why] of [
[['--send'], /^refused: --send needs the preview mode/],
[['--send', '--public-host', NAME, '--auth-header', HEADER], /^refused: preview mode needs/],
[['--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], /^refused: --send needs the bus configured \(/],
[['--send', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID], /^refused: --send needs the bus configured \(/],
]) {
const result = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', ...flags], { encoding: 'utf8', timeout: 10000, env });
assert.equal(result.status, 2, flags.join(' '));
assert.match(result.stderr, why, flags.join(' '));
assert.equal(result.stderr.includes(root), false, 'the refusal names the config path');
assert.equal(result.stdout, '');
}
});
test('the CLI with --send and a configured bus sends through the human transport and logs one line', async t => {
// No bus host is running, so the send is a definite refusal: no-bus-host, nothing written.
const root = tmp(t, 'webui-send-cli-');
const { env } = fixture(root);
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--public-host', NAME, '--auth-header', HEADER, '--allow-user', ID, '--send'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => child.kill());
let out = '';
const port = await new Promise((resolve, reject) => {
child.stdout.on('data', c => { out += c; const m = out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\/$/m); if (m && out.includes('Ctrl-C')) resolve(Number(m[1])); });
child.on('exit', code => reject(new Error(`exited ${code}: ${out}`)));
});
assert.match(out, new RegExp(`^Preview: https://${NAME}/ through the proxy, signed-in user from ${HEADER}, reads and human messages to roles \\(--send\\); one line per send below$`, 'm'));
const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'secret-body-text' }) });
assert.equal(r.status, 503);
assert.deepEqual(r.body, { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false });
const SEND = /^\d{4}-\d\d-\d\dT[\d:.]+Z send /m;
for (let i = 0; i < 100 && !SEND.test(out); i++) await new Promise(res => setTimeout(res, 20));
const sendLines = out.split('\n').filter(l => SEND.test(l));
assert.equal(sendLines.length, 1, out);
assert.match(sendLines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" error=no-bus-host$/);
assert.equal(out.includes('secret-body-text'), false);
});
test('without --send the route is read-only like every other write, and config says no send', async t => {
const { port, sends, lines } = await setup(t, { send: null });
const r = await post(port);
assert.equal(r.status, 403); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' });
const config = await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined });
assert.equal(config.body.send, false);
assert.deepEqual(sends, []); assert.deepEqual(lines, []);
});
test('with --send every other write is still 403 read-only, and nothing reaches the board or bus', async t => {
const { port, sends, lines, boardHits, busCalls } = await setup(t);
for (const [method, path] of [['POST', '/api/seen'], ['POST', '/api/reply'], ['POST', '/api/bus/inbox'], ['POST', '/api/bus/message/x'], ['POST', '/api/bus/messages'], ['POST', '/'], ['PUT', '/api/seen'], ['DELETE', '/api/reply'], ['PATCH', '/api/bus/inbox']]) {
const r = await post(port, { method, path });
assert.equal(r.status, 403, `${method} ${path}`); assert.deepEqual(r.body, { error: 'read-only', message: 'the Console preview is read-only' }, `${method} ${path}`);
}
assert.deepEqual([sends, lines, boardHits, busCalls], [[], [], [], []]);
});
test('config says send only on the public name with --send on', async t => {
const { port } = await setup(t);
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { [HEADER]: ID }, body: undefined })).body.send, true);
assert.equal((await post(port, { method: 'GET', path: '/api/config', headers: { host: `127.0.0.1:${port}` }, body: undefined })).body.send, false);
});
test('a send without the signed-in user is 401 and is neither sent nor logged, loopback included', async t => {
const { port, sends, lines } = await setup(t);
for (const [why, headers] of [
['no header', without(HEADER)],
['another user', { ...ok, [HEADER]: 'mallory' }],
['the id with a suffix', { ...ok, [HEADER]: ID + 'x' }],
['two values', { ...ok, [HEADER]: [ID, ID] }],
['loopback without the header', { ...without(HEADER), host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}` }],
['localhost, no Origin, no header', { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', host: `localhost:${port}` }],
]) {
const r = await post(port, { headers });
assert.equal(r.status, 401, why); assert.deepEqual(r.body, { error: 'sign-in required' }, why);
}
// Loopback with the header is a proxy that rewrote Host: refused before the route (row 74).
const rewritten = await post(port, { headers: { ...ok, host: `127.0.0.1:${port}` } });
assert.equal(rewritten.status, 403); assert.equal(rewritten.body.error, 'proxy Host refused');
assert.deepEqual(sends, []); assert.deepEqual(lines, []);
});
test('each request check refuses before anything is sent, and logs the refusal without the body', async t => {
const { port, sends, lines } = await setup(t);
const cases = [
['no Origin', { headers: without('origin') }, 403, 'cross-origin request refused'],
['no Sec-Fetch-Site', { headers: without('sec-fetch-site') }, 403, 'cross-site request refused'],
...['cross-site', 'same-site', 'none', 'Same-Origin', 'same-origin, same-origin'].map(v => [`Sec-Fetch-Site ${v}`, { headers: { ...ok, 'sec-fetch-site': v } }, 403, 'cross-site request refused']),
['two Sec-Fetch-Site', { headers: { ...ok, 'sec-fetch-site': ['same-origin', 'same-origin'] } }, 403, 'cross-site request refused'],
['no Content-Type', { headers: without('content-type') }, 400, 'invalid-request'],
['text/plain', { headers: { ...ok, 'content-type': 'text/plain' } }, 400, 'invalid-request'],
['form', { headers: { ...ok, 'content-type': 'application/x-www-form-urlencoded' }, body: 'to=coder&body=hi' }, 400, 'invalid-request'],
['oversize', { body: JSON.stringify({ to: 'coder', body: 'x'.repeat(16384) }) }, 400, 'invalid-request'],
['an extra key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', class: 'REQUEST' }) }, 400, 'invalid-request'],
['the business key', { body: JSON.stringify({ to: 'coder', body: 'secret-body-text', business: 'other' }) }, 400, 'invalid-request'],
['no to', { body: JSON.stringify({ body: 'secret-body-text' }) }, 400, 'invalid-request'],
['no body', { body: JSON.stringify({ to: 'coder' }) }, 400, 'invalid-request'],
['not JSON', { body: '{"to":' }, 400, 'invalid-request'],
['null', { body: 'null' }, 400, 'invalid-request'],
['an array', { body: '["coder","hi"]' }, 400, 'invalid-request'],
['a string', { body: '"hi"' }, 400, 'invalid-request'],
];
for (const [why, req, status, error] of cases) {
const r = await post(port, req);
assert.equal(r.status, status, why); assert.equal(r.body.error, error, why); assert.equal(r.body.sent, false, why);
}
// An Origin that isn't this one is refused by the shared check, before the route.
for (const origin of [`http://${NAME}`, 'https://evil.example', `https://${NAME}:443`, 'null']) {
const r = await post(port, { headers: { ...ok, origin } });
assert.equal(r.status, 403, origin); assert.deepEqual(r.body, { error: 'cross-origin request refused' }, origin);
}
assert.equal((await post(port, { method: 'GET', body: undefined })).status, 405);
assert.equal((await post(port, { method: 'PUT' })).status, 405);
assert.deepEqual(sends, []);
assert.equal(lines.length, cases.length);
for (const l of lines) assert.match(l, /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to=("coder"|none) error=[a-z -]+$/, l);
assert.equal(lines.some(l => l.includes('secret-body-text')), false);
// 16 KiB fits: the body limit is the broker's beyond that.
const fits = JSON.stringify({ to: 'coder', body: 'x'.repeat(16384 - JSON.stringify({ to: 'coder', body: '' }).length) });
assert.equal(Buffer.byteLength(fits), 16384);
assert.equal((await post(port, { body: fits })).status, 200);
});
test('a send passes exactly to, body and in_reply_to, answers the id, and logs it', async t => {
const { port, sends, lines, boardHits, busCalls } = await setup(t);
const r = await post(port, { body: JSON.stringify({ body: 'secret-body-text', to: 'coder' }) });
assert.equal(r.status, 200);
assert.equal(r.body.id, 'msg-0001'); assert.match(r.body.at, /^\d{4}-\d\d-\d\dT/); assert.deepEqual(Object.keys(r.body), ['id', 'at']);
const reply = await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }) });
assert.equal(reply.status, 200);
// Values go to the broker as given, whatever their type: it is the one that refuses them.
assert.equal((await post(port, { body: JSON.stringify({ to: 7, body: ['x'] }) })).status, 200);
assert.equal((await post(port, { body: JSON.stringify({ to: 'pm', body: 'yes', in_reply_to: '' }) })).status, 200);
assert.deepEqual(sends, [{ to: 'coder', body: 'secret-body-text' }, { to: 'pm', body: 'yes', in_reply_to: 'msg-0000' }, { to: 7, body: ['x'] }, { to: 'pm', body: 'yes', in_reply_to: '' }]);
assert.deepEqual(sends.map(Object.keys), [['to', 'body'], ['to', 'body', 'in_reply_to'], ['to', 'body'], ['to', 'body', 'in_reply_to']]);
assert.equal(lines.length, 4);
assert.match(lines[0], /^\d{4}-\d\d-\d\dT[\d:.]+Z send user="jason" to="coder" id=msg-0001$/);
assert.match(lines[2], / to=none id=msg-0001$/);
assert.equal(lines.some(l => l.includes('secret-body-text')), false);
assert.deepEqual(boardHits, []); assert.deepEqual(busCalls, []);
});
test('a crafted role name cannot forge a log line', async t => {
const { port, lines, state } = await setup(t);
state.answer = async () => { throw new BusReadError('unknown-role'); };
const to = 'coder error=x\n2026-01-01T00:00:00.000Z send user="jason" to="pm" id=forged' + 'y'.repeat(100);
await post(port, { body: JSON.stringify({ to, body: 'hi' }) });
assert.equal(lines.length, 1);
assert.equal(lines[0].includes('\n'), false);
assert.match(lines[0], / error=unknown-role$/);
assert.ok(lines[0].includes(` to=${JSON.stringify(to.slice(0, 64))} error=`));
// The cut is at 64 characters: a 65-character role loses its last one.
await post(port, { body: JSON.stringify({ to: 'r'.repeat(64) + 's', body: 'hi' }) });
assert.match(lines[1], new RegExp(` to="${'r'.repeat(64)}" error=unknown-role$`));
});
test('a bus refusal is not sent, with its status; an outcome the bus could not confirm is unknown and never retried', async t => {
const { port, sends, lines, state } = await setup(t);
for (const [answer, status, error, sent] of [
[new BusReadError('unknown-role'), 400, 'unknown-role', false],
[new BusReadError('invalid-request'), 400, 'invalid-request', false],
[new BusReadError('message-not-found'), 400, 'message-not-found', false],
[new BusReadError('request-too-large'), 400, 'request-too-large', false],
[new BusReadError('human-required'), 403, 'human-required', false],
[new BusReadError('read-only'), 403, 'read-only', false],
[new BusReadError('no-bus-host'), 503, 'no-bus-host', false],
[new BusReadError('something-new'), 502, 'something-new', false],
[new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'],
[new BusReadError('invalid-response'), 503, 'outcome-unknown', 'unknown'],
[new BusReadError('response-too-large'), 503, 'outcome-unknown', 'unknown'],
[Object.assign(new Error('ENOENT: /home/someone/.mosaic-dev/bus/broker.sock'), { code: 'ENOENT' }), 503, 'outcome-unknown', 'unknown'],
[{ id: '' }, 503, 'outcome-unknown', 'unknown'],
[null, 503, 'outcome-unknown', 'unknown'],
[{ id: '../x' }, 503, 'outcome-unknown', 'unknown'],
]) {
const before = sends.length;
state.answer = async () => { if (answer instanceof Error) throw answer; return answer; };
const r = await post(port);
const label = answer instanceof Error ? answer.code ?? answer.message : JSON.stringify(answer);
assert.equal(sends.length, before + 1, `${label}: sent ${sends.length - before} times`);
assert.equal(r.status, status, label); assert.equal(r.body.error, error, label); assert.equal(r.body.sent, sent, label);
assert.doesNotMatch(JSON.stringify(r.body), /home|mosaic-dev|\.sock/);
if (sent === 'unknown') assert.match(r.body.message, /may have been sent; nothing was retried|may have been sent/, label);
assert.match(lines.at(-1), new RegExp(` error=${error}$`), label);
}
});
// The same protocol as the real transport: a script standing in for human-cli.mjs.
const FAKE = `
import { readFileSync, appendFileSync } from 'node:fs';
const request = JSON.parse(readFileSync(0, 'utf8'));
appendFileSync(process.env.FAKE_LOG, JSON.stringify(request) + '\\n');
if (request.args.to === 'nobody') { process.stderr.write('unknown-role\\n'); process.exit(2); }
if (request.args.to === 'crash') process.exit(1);
process.stdout.write(JSON.stringify({ id: 'msg-fake-1', request: 'req-1' }));
`;
test('through the human transport the request is exactly the business, message.send and the keys', async t => {
const root = tmp(t, 'webui-send-transport-');
const cli = join(root, 'fake-human-cli.mjs'), log = join(root, 'requests.jsonl');
writeFileSync(cli, FAKE);
const call = humanCall({ socket: '/run/fake.sock', business: 'demo', cli, env: { ...process.env, FAKE_LOG: log }, timeoutMs: 5000 });
const { port, lines } = await setup(t, { send: args => call('message.send', args) });
const r = await post(port);
assert.equal(r.status, 200); assert.equal(r.body.id, 'msg-fake-1');
const refused = await post(port, { body: JSON.stringify({ to: 'nobody', body: 'hi' }) });
assert.deepEqual([refused.status, refused.body.error, refused.body.sent], [400, 'unknown-role', false]);
const crashed = await post(port, { body: JSON.stringify({ to: 'crash', body: 'hi' }) });
assert.deepEqual([crashed.status, crashed.body.error, crashed.body.sent], [503, 'outcome-unknown', 'unknown']);
const { readFileSync } = await import('node:fs');
const requests = readFileSync(log, 'utf8').trim().split('\n').map(l => JSON.parse(l));
assert.deepEqual(requests, [
{ business: 'demo', verb: 'message.send', args: { to: 'coder', body: 'Please look at row 76.' } },
{ business: 'demo', verb: 'message.send', args: { to: 'nobody', body: 'hi' } },
{ business: 'demo', verb: 'message.send', args: { to: 'crash', body: 'hi' } },
]);
assert.equal(lines.length, 3);
});
test('against a real broker: the message is written once as a human send, and its refusals map', async t => {
const { b, human, transport } = broker(t);
const call = transport('demo');
// The transport's refusal shape, as humanCall gives it.
const send = async args => { try { return await call('message.send', args); } catch (e) { throw new BusReadError(e.code); } };
const { port } = await setup(t, { send });
const r = await post(port, { body: JSON.stringify({ to: 'coder', body: 'Please look at row 76.' }) });
assert.equal(r.status, 200);
const messages = () => b.request(human, { verb: 'trail', args: { subject: r.body.id } }).filter(row => row.table === 'messages');
assert.deepEqual(messages().map(m => [m.id, m.from_role, m.from_run, m.to_role, m.class, m.body]), [[r.body.id, 'human', 'human-cli', 'coder', 'INFO', 'Please look at row 76.']]);
for (const [body, status, error] of [
[{ to: 'no-such-role', body: 'hi' }, 400, 'unknown-role'],
[{ to: 'coder', body: 'x'.repeat(32769) }, 400, 'invalid-request'],
[{ to: 'coder', body: '' }, 400, 'invalid-request'],
[{ to: 7, body: 'hi' }, 400, 'unknown-role'],
[{ to: 'coder', body: 'hi', in_reply_to: 'msg-none' }, 400, 'message-not-found'],
]) {
// The 32769-character body is past the Console's 16 KiB too; the Console refuses that one first.
const res = await post(port, { body: JSON.stringify(body) });
assert.deepEqual([res.status, res.body.error, res.body.sent], [status, error, false], JSON.stringify(body).slice(0, 60));
}
assert.equal(messages().length, 1);
assert.equal(b.request(human, { verb: 'trail', args: { subject: 'msg-none' } }).length, 0);
});
+5 -2
View File
@@ -50,9 +50,12 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
// No Resolve control and no bus write: the only POSTs are the board's Seen and reply.
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has one write,
// row 76's message to a role, and it sends only to and body.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/);
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
assert.deepEqual(writes, ["fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 1);
assert.equal(read('icons.svg').toString().includes('<script'), false);
});