Rebased on row 59 (a8512007). Darkwing's N1-N5 and the Q1 proxy line (lead decision 99), Filbert's N1-N4 (lead decision 100): a 408 is unknown on the send box and the board Reply, the receipt shows the code once, focus and caret kept, a role no longer held kept, Inbox current on a message trail, README notes. 35 of 35 mutants killed; gate green on729da72b. Manifest sha256 04ecf1b57c594a35d34d53277b2df915a571f4fa9f7cc29146dbedfa612f77d4. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1131 lines
78 KiB
Diff
1131 lines
78 KiB
Diff
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
|
index c866804c..5ac12d95 100644
|
|
--- a/packages/webui/README.md
|
|
+++ b/packages/webui/README.md
|
|
@@ -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
|
|
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
|
|
index ca1bab89..379503f5 100644
|
|
--- a/packages/webui/src/bus.mjs
|
|
+++ b/packages/webui/src/bus.mjs
|
|
@@ -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;
|
|
diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs
|
|
index 5c6a5931..618a2cf1 100644
|
|
--- a/packages/webui/src/cli.mjs
|
|
+++ b/packages/webui/src/cli.mjs
|
|
@@ -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) {
|
|
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
|
|
index d321b483..9b50235c 100644
|
|
--- a/packages/webui/src/public/app.js
|
|
+++ b/packages/webui/src/public/app.js
|
|
@@ -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 => {
|
|
diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js
|
|
index b22bcf00..ffcc7cf5 100644
|
|
--- a/packages/webui/src/public/bus.js
|
|
+++ b/packages/webui/src/public/bus.js
|
|
@@ -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
|
|
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
|
index 89327c07..6209f9b9 100644
|
|
--- a/packages/webui/src/serve.mjs
|
|
+++ b/packages/webui/src/serve.mjs
|
|
@@ -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);
|
|
diff --git a/packages/webui/tests/send-browser.test.mjs b/packages/webui/tests/send-browser.test.mjs
|
|
new file mode 100644
|
|
index 00000000..dd7c5668
|
|
--- /dev/null
|
|
+++ b/packages/webui/tests/send-browser.test.mjs
|
|
@@ -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 });
|
|
+ }
|
|
+});
|
|
diff --git a/packages/webui/tests/send.test.mjs b/packages/webui/tests/send.test.mjs
|
|
new file mode 100644
|
|
index 00000000..11cdb631
|
|
--- /dev/null
|
|
+++ b/packages/webui/tests/send.test.mjs
|
|
@@ -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);
|
|
+});
|
|
diff --git a/packages/webui/tests/shell.test.mjs b/packages/webui/tests/shell.test.mjs
|
|
index d27e0fbd..fbea206c 100644
|
|
--- a/packages/webui/tests/shell.test.mjs
|
|
+++ b/packages/webui/tests/shell.test.mjs
|
|
@@ -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);
|
|
});
|
|
|