feat(bus): decisions routed to roles and recent decisions (#1546, row 57)

The broker gains the reader verb `decisions` with `{list: 'roles' |
'recent'}`: the open decisions routed to each role, and the last 50
decisions (RECENT). views.mjs exposes it in the Q1 client module, and
webui serves `GET /api/bus/decisions?list=roles|recent`. Any other list
is 400 and never reaches the bus. No Inbox tab uses the route yet; the
tabs are the design rows' work. The bus and webui READMEs list the verb,
the route and who may call it.

Filbert built it (agents/filbert/work/queue-57). Round 1 approve from
Darkwing (27290) and Dewey (27300) on manifest af3b03cf, 8 paths. Their
non-blocking notes (no seen-then-resolve test, no webui test of ?list=
before the no-bus 503, test 4 compares fewer fields, roles has no LIMIT,
3 of 14 mutants survived) go to a follow-up row.

Sage's gate on 3c79809c, sequential and teed: node suites bus 80 and
webui 40, 0 failed; test-auth 15, test-conductor 17, test-config 24,
test-discord 66, test-extension-package 18, test-foundation 44,
test-queue 27, test-release 14, test-task 98, all 0 failed.
958668a8..3c79809c touches none of the 8 paths.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-11 15:36:52 -05:00
co-authored by Claude Opus 5.5
parent 3c79809ca0
commit f78ba57d70
8 changed files with 298 additions and 18 deletions
+12 -2
View File
@@ -196,8 +196,17 @@ must not invoke the human CLI or work around its proof.
Human resolutions and launch toggles append `human.input`. Agent capabilities Human resolutions and launch toggles append `human.input`. Agent capabilities
and read-only WebUI capabilities cannot reach these operations. Reader and read-only WebUI capabilities cannot reach these operations. Reader
capabilities expose the human inbox plus tasks/agents/trail for one business; capabilities expose the human inbox plus tasks/agents/trail/decisions for one
they do not confer mutation or resolution authority. business; they do not confer mutation or resolution authority.
`decisions` (row 57, #1546) is for human and reader sessions only; a role
session gets `human-required`, so it can't list another role's open
decisions. It takes `{list}`, nothing else. `roles` lists the business's open
decisions routed to a role, never to the human, oldest first. `recent` lists
its last `RECENT` (50) closed decisions of every route, newest close first,
each with `closed`, the closing `decision_events` row. Every row is the
decision as `trail` gives the same session for its id, so the lists add no
data a reader couldn't already read one id at a time; they add the listing.
## Shared reads and adapter boundary ## Shared reads and adapter boundary
@@ -207,6 +216,7 @@ import {views} from './src/views.mjs';
const read = views(new Client({path,cap})); const read = views(new Client({path,cap}));
await read.inbox(); await read.tasks(); await read.agents(); await read.inbox(); await read.tasks(); await read.agents();
await read.trail('vikunja:3/41'); await read.trail('vikunja:3/41');
await read.decisions('roles'); await read.decisions('recent');
``` ```
S4 and S5 must import this client-only module. Neither opens `bus.sqlite`. S4 and S5 must import this client-only module. Neither opens `bus.sqlite`.
+25 -1
View File
@@ -38,6 +38,8 @@ const GATED = new Set([
'role.revoke', 'role.revoke',
]); ]);
const CLOSED = "('resolved','withdrawn','expired')"; const CLOSED = "('resolved','withdrawn','expired')";
// How many closed decisions `decisions` with list 'recent' returns, newest close first.
export const RECENT = 50;
export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.'))); export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task.')));
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']); const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([ const POLL_KINDS = new Set([
@@ -466,7 +468,8 @@ export class Broker {
if (!object(args)) fail('invalid-request'); if (!object(args)) fail('invalid-request');
this.#secretCheck(args); this.#secretCheck(args);
return this.#store.transaction(() => { return this.#store.transaction(() => {
if (s.reader && !['inbox', 'agents', 'tasks', 'trail'].includes(request.verb)) fail('read-only'); if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions'].includes(request.verb))
fail('read-only');
const result = this.#dispatch(s, request.verb, args); const result = this.#dispatch(s, request.verb, args);
this.#secretCheck(result); this.#secretCheck(result);
return result; return result;
@@ -833,6 +836,27 @@ export class Broker {
.map((r) => ({ ...r, fields: JSON.parse(r.fields) })); .map((r) => ({ ...r, fields: JSON.parse(r.fields) }));
return this.#trail(s, identifier(a.subject)); return this.#trail(s, identifier(a.subject));
} }
// Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists, for human and
// reader sessions only. Each row is what trail gives the same session for that id.
case 'decisions': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['list'], ['list']);
if (a.list === 'roles')
return this.#store
.all(
`SELECT d.* FROM decisions d WHERE business=? AND route_to<>'human' AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY seq`,
s.business,
)
.map((d) => this.#decisionView(d));
if (a.list === 'recent')
return this.#store
.all(
`SELECT d.* FROM decisions d JOIN decision_events x ON x.decision=d.id AND x.op IN ${CLOSED} WHERE d.business=? ORDER BY x.seq DESC LIMIT ${RECENT}`,
s.business,
)
.map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));
fail('invalid-request');
}
default: default:
fail('unknown-verb'); fail('unknown-verb');
} }
+1
View File
@@ -5,5 +5,6 @@ export function views(client) {
tasks: () => client.call('tasks'), tasks: () => client.call('tasks'),
agents: () => client.call('agents'), agents: () => client.call('agents'),
trail: (subject) => client.call('trail', { subject }), trail: (subject) => client.call('trail', { subject }),
decisions: (list) => client.call('decisions', { list }),
}); });
} }
+193
View File
@@ -0,0 +1,193 @@
// Row 57 (#1546): `decisions`, the read behind the Inbox tabs "Routed to roles"
// and "Recent". Human and reader sessions read it; a role session can't.
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 '../src/store.mjs';
import { Broker, RECENT } from '../src/broker.mjs';
const options = [
{ key: 'yes', text: 'Allow' },
{ key: 'no', text: 'Decline' },
];
const roles = {
pm: { authority: { withinRole: ['message.send', 'task.create'], crossRole: [] } },
cto: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['message.send'], crossRole: ['task.scope.change'] } },
};
const businesses = {
demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
other: { id: 'other', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles },
};
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-decisions-'));
const store = new Store(root);
const b = new Broker({ store, businesses });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const agent = (role, business = 'demo', run = `${business}-${role}-run`) => {
const cap = b.bindLaunch({ business, role, run, harness: 'pi', address: run });
b.request(cap, { verb: 'role.claim' });
return cap;
};
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const reader = b.bindReader({ business: 'demo' });
return { b, store, agent, human, reader };
}
const call = (b, cap, verb, args = {}) => b.request(cap, { verb, args });
const raise = (b, cap, action, extra = {}) =>
call(b, cap, 'decision.raise', { action, question: `${action}?`, options, recommendation: 'no', blocking: false, ...extra });
const list = (b, cap, which) => call(b, cap, 'decisions', { list: which });
// Open: one routed to cto, one to pm, one to the human. Closed: one within-role.
function seed(t) {
const x = setup(t);
const { b, agent } = x;
const coder = agent('coder');
const cto = agent('cto');
const pm = agent('pm');
const toCto = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/1' });
const toPm = raise(b, coder, 'task.scope.change', { domain: 'delivery', target: 'vikunja:3/2' });
const toHuman = raise(b, coder, 'deploy', { target: 'release-1', task_ref: 'vikunja:3/3', blocking: true });
const within = raise(b, coder, 'message.send', { choice: 'yes' });
assert.deepEqual([toCto.route_to, toPm.route_to, toHuman.route_to, within.route_to], ['cto', 'pm', 'human', 'coder']);
return { ...x, coder, cto, pm, toCto, toPm, toHuman, within };
}
test('roles lists open decisions routed to a role, and never the human inbox', (t) => {
const { b, human, reader, toCto, toPm, toHuman } = seed(t);
for (const cap of [human, reader]) {
const rows = list(b, cap, 'roles');
assert.deepEqual(
rows.map((r) => [r.id, r.route_to]),
[
[toCto.id, 'cto'],
[toPm.id, 'pm'],
],
);
// The same view as inbox: options parsed, blocking a boolean, the authorization context.
assert.deepEqual(rows[0].options, options);
assert.equal(rows[0].blocking, false);
assert.deepEqual(rows[0].authorization, { action: 'task.scope.change', target: 'vikunja:3/1', approvalChoice: 'yes' });
assert.deepEqual(
call(b, cap, 'inbox').map((r) => r.id),
[toHuman.id],
);
}
});
test('recent lists closed decisions of every route, newest close first, with how each closed', (t) => {
const { b, store, human, reader, coder, cto, toCto, toPm, toHuman, within } = seed(t);
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'no', note: 'not today' });
call(b, cto, 'decision.resolve', { id: toCto.id, choice: 'yes' });
call(b, coder, 'decision.withdraw', { id: toPm.id });
const late = raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:3/4' });
store.run(
"INSERT INTO decision_events(decision,at,op,by) VALUES(?,'2026-10-11T00:00:00.000Z','expired','broker')",
late.id,
);
for (const cap of [human, reader]) {
const rows = list(b, cap, 'recent');
assert.deepEqual(
rows.map((r) => [r.id, r.closed.op, r.closed.by, r.closed.choice, r.closed.via]),
[
[late.id, 'expired', 'broker', null, null],
[toPm.id, 'withdrawn', 'demo-coder-run', null, 'broker'],
[toCto.id, 'resolved', 'demo-cto-run', 'yes', 'broker'],
[toHuman.id, 'resolved', 'jason', 'no', 'cli'],
[within.id, 'resolved', 'demo-coder-run', 'yes', 'broker'],
],
);
assert.equal(rows[3].closed.note, 'not today');
assert.deepEqual(rows[3].options, options);
assert.equal(rows[3].blocking, true);
assert.deepEqual(list(b, cap, 'roles'), []);
}
});
test('recent stops at RECENT rows', (t) => {
const { b, reader, coder } = seed(t);
const ids = [];
for (let i = 0; i < RECENT; i++) ids.push(raise(b, coder, 'message.send', { choice: 'yes' }).id);
const rows = list(b, reader, 'recent');
assert.equal(rows.length, RECENT);
assert.deepEqual(
rows.map((r) => r.id),
ids.reverse(),
);
});
test('every row is what trail already gives the same session for that id', (t) => {
const { b, human, reader, toHuman } = seed(t);
call(b, human, 'decision.resolve', { id: toHuman.id, choice: 'yes' });
for (const cap of [human, reader])
for (const row of [...list(b, cap, 'roles'), ...list(b, cap, 'recent')]) {
const trail = call(b, cap, 'trail', { subject: row.id });
const d = trail.find((r) => r.table === 'decisions' && r.id === row.id);
assert.ok(d, row.id);
for (const k of ['route_to', 'class', 'action', 'question', 'recommendation', 'raised_by_role', 'raised_by_run'])
assert.equal(d[k], row[k], k);
assert.deepEqual(d.options, row.options);
if (row.closed) {
const { table, ...closed } = trail.find((r) => r.table === 'decision_events' && r.seq === row.closed.seq);
assert.equal(table, 'decision_events');
assert.deepEqual(closed, { ...row.closed });
}
}
});
test("a role session can't list decisions, so it can't read another role's open decisions", (t) => {
const { b, store, agent, coder, cto, pm, toCto, toPm, toHuman } = seed(t);
const idle = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-unclaimed', harness: 'pi', address: 'x' });
const refusals = () => store.get("SELECT count(*) AS n FROM events WHERE kind='action.refused'").n;
for (const cap of [cto, pm, coder, idle])
for (const which of ['roles', 'recent', 'bogus']) {
const before = refusals();
assert.throws(() => list(b, cap, which), /human-required/, which);
assert.equal(refusals(), before + 1, 'the refusal is recorded');
}
// Its own inbox stays its own: cto sees cto's, not pm's or the human's.
assert.deepEqual(
call(b, cto, 'inbox').map((r) => r.id),
[toCto.id],
);
assert.deepEqual(
call(b, pm, 'inbox').map((r) => r.id),
[toPm.id],
);
assert.ok(!call(b, coder, 'inbox').some((r) => r.id === toHuman.id));
// A role in another business reads none of demo's either.
assert.throws(() => list(b, agent('cto', 'other'), 'roles'), /human-required/);
});
test('decisions is a read: one business, strict arguments, nothing written', (t) => {
const { b, store, human, reader, agent } = seed(t);
const coder = agent('coder', 'other');
raise(b, coder, 'task.scope.change', { domain: 'technical', target: 'vikunja:9/1' });
raise(b, coder, 'message.send', { choice: 'yes' });
const otherReader = b.bindReader({ business: 'other' });
assert.equal(list(b, otherReader, 'roles').length, 1);
assert.equal(list(b, otherReader, 'recent').length, 1);
assert.ok(list(b, otherReader, 'roles').every((r) => r.business === 'other'));
assert.ok(list(b, reader, 'recent').every((r) => r.business === 'demo'));
const count = () =>
['events', 'decisions', 'decision_events', 'messages', 'deliveries', 'role_claims'].map(
(table) => store.get(`SELECT count(*) AS n FROM ${table}`).n,
);
const before = count();
for (const cap of [human, reader]) {
list(b, cap, 'roles');
list(b, cap, 'recent');
}
assert.deepEqual(count(), before, 'a successful read writes nothing');
for (const cap of [human, reader])
for (const args of [{}, { list: 'all' }, { list: 'roles', limit: 5 }, { list: ['roles'] }, { list: 'Recent' }])
assert.throws(() => call(b, cap, 'decisions', args), /invalid-request/, JSON.stringify(args));
// The reader's other limits stand.
assert.throws(() => call(b, reader, 'decision.seen', { id: list(b, reader, 'roles')[0].id }), /read-only/);
});
+10 -5
View File
@@ -193,17 +193,22 @@ configured board URL for the page's error message. No scanner, registration,
session reader or transport is implemented here. The session reader is session reader or transport is implemented here. The session reader is
`packages/conversation`, served by the board. `packages/conversation`, served by the board.
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents` and GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
`/api/bus/trail?subject=<id>` are the bus reads (`src/bus.mjs`). Any other `/api/bus/trail?subject=<id>` and `/api/bus/decisions?list=roles|recent` are
method is 405. The subject must match the broker's identifier rule, else 400. the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
the broker's identifier rule, and the list must be `roles` or `recent`, else
400. `decisions` (row 57, #1546) is for the Inbox tabs "Routed to roles" and
"Recent"; "Human and reader paths" in `packages/bus/README.md` has what each
list holds.
Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as Each read runs the S4 human transport (`packages/bus/src/human-cli.mjs`) as
a child process, so a slow read doesn't stall the server. The bus checks a child process, so a slow read doesn't stall the server. The bus checks
that child's ancestry, which ends at the Console process; it never sees the that child's ancestry, which ends at the Console process; it never sees the
browser or any other client of the port. So while the Console runs, browser or any other client of the port. So while the Console runs,
anything that can connect to its port (7330 by default) reads what a reader anything that can connect to its port (7330 by default) reads what a reader
capability reads ("Human and reader paths" in `packages/bus/README.md`): capability reads ("Human and reader paths" in `packages/bus/README.md`):
the inbox, tasks, agents and trails. That includes a T3 seat using `curl` the inbox, tasks, agents, trails and the two decision lists. That includes
and a managed S6 session, since S6 doesn't confine the network. No route 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 writes (Q4), so the exposure is reads only. Slice 1 doesn't change this
(Filbert F1 on #1522). Answers are `{ rows, at }`; failures are `{ error, message }` (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 with 403 for a refusal, 503 for no bus or no answer, and 502 for an answer
+3 -2
View File
@@ -1,8 +1,9 @@
// Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human // Slice 1 S5 (#1522): the Console's read of the bus. It runs the S4 human
// transport (packages/bus/src/human-cli.mjs, lead decision 70) as a child // 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. // with spawn, not spawnSync, so one slow read never stalls the HTTP server.
// Only the four verbs of the Q1 module (lead decision 56) are reachable: // Only the four verbs of the Q1 module (lead decision 56) and row 57's
// the WebUI writes nothing, not even seen (Q4). The bus does the proof; a // `decisions` read 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. // server started inside an agent run gets `human-required` from the broker.
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
+11 -5
View File
@@ -46,16 +46,22 @@ async function body(req) {
return bytes; return bytes;
} }
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4). // Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail']; // Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];
const DECISION_LISTS = ['roles', 'recent'];
async function busRead(res, bus, verb, search) { async function busRead(res, bus, verb, search) {
let subject; let arg;
if (verb === 'trail') { if (verb === 'trail') {
subject = new URLSearchParams(search).get('subject'); arg = new URLSearchParams(search).get('subject');
if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' }); if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
}
if (verb === 'decisions') {
arg = new URLSearchParams(search).get('list');
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
} }
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' }); if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
try { try {
const rows = await bus[verb](subject); const rows = await bus[verb](arg);
if (!Array.isArray(rows)) throw new BusReadError('invalid-response'); if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
return json(res, 200, { rows, at: new Date().toISOString() }); return json(res, 200, { rows, at: new Date().toISOString() });
} catch (err) { } catch (err) {
+43 -3
View File
@@ -87,13 +87,52 @@ test('bus routes serve the four reads from the reader view, unescaped JSON for t
assert.deepEqual([...new Set(calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']); assert.deepEqual([...new Set(calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
}); });
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => { // Row 57 (#1546): the Inbox's "Routed to roles" and "Recent" lists.
test('decisions route serves the roles and recent lists from the reader view', async t => {
const store = new Store(tmp(t, 'webui-bus-decisions-'));
t.after(() => store.close());
const b = new Broker({ store, businesses });
const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
b.request(coder, { verb: 'role.claim' });
const raise = args => b.request(coder, { verb: 'decision.raise', args: { options: [{ key: 'yes', text: 'Allow' }, { key: 'no', text: 'Decline' }], recommendation: 'no', blocking: false, ...args } });
const routed = raise({ action: 'task.scope.change', domain: 'technical', target: 'vikunja:32/8', question: `Split it? ${HOSTILE}` });
const human = raise({ action: 'deploy', target: 'release-1', question: 'Deploy?' });
const closed = raise({ action: 'message.send', question: 'Send?', choice: 'yes' });
const reader = b.bindReader({ business: 'demo' });
const calls = []; const calls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail'].map(v => [v, async () => { calls.push(v); return []; }])); const bus = views({ call: async (verb, args = {}) => { calls.push([verb, args]); return structuredClone(b.request(reader, { verb, args })); } });
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus }); const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web)); t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`; const base = `http://127.0.0.1:${web.address().port}`;
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method); const read = async path => { const r = await fetch(base + path); return { status: r.status, cache: r.headers.get('cache-control'), body: await r.json() }; };
const roles = await read('/api/bus/decisions?list=roles');
assert.equal(roles.status, 200); assert.equal(roles.cache, 'no-store');
assert.match(roles.body.at, /^\d{4}-\d\d-\d\dT/);
assert.deepEqual(roles.body.rows.map(r => [r.id, r.route_to]), [[routed.id, 'cto']]);
assert.ok(roles.body.rows[0].question.endsWith(HOSTILE));
const recent = await read('/api/bus/decisions?list=recent');
assert.deepEqual(recent.body.rows.map(r => [r.id, r.closed.op, r.closed.choice]), [[closed.id, 'resolved', 'yes']]);
assert.deepEqual((await read('/api/bus/inbox')).body.rows.map(r => r.id), [human.id]);
assert.deepEqual(calls.filter(([v]) => v === 'decisions'), [['decisions', { list: 'roles' }], ['decisions', { list: 'recent' }]]);
// A missing or unknown list never reaches the bus.
const before = calls.length;
for (const q of ['', '?list=', '?list=all', '?list=Roles', '?list=roles%00', '?subject=roles'])
assert.deepEqual([q, (await read('/api/bus/decisions' + q)).body.error], [q, 'invalid-request']);
assert.equal(calls.length, before);
});
test('bus routes are GET-only, have no write verb, and keep the same-origin checks', async t => {
const calls = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => { calls.push(v); return []; }]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus });
t.after(() => close(web));
const base = `http://127.0.0.1:${web.address().port}`;
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS']) {
assert.equal((await fetch(base + '/api/bus/inbox', { method })).status, 405, method);
assert.equal((await fetch(base + '/api/bus/decisions?list=roles', { method })).status, 405, method);
}
for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) { for (const path of ['/api/bus/decide', '/api/bus/seen', '/api/bus/', '/api/bus/inbox/x', '/api/bus/request', '/api/bus/raise']) {
assert.equal((await fetch(base + path)).status, 404, path); assert.equal((await fetch(base + path)).status, 404, path);
assert.equal((await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 404, path); assert.equal((await fetch(base + path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 404, path);
@@ -176,6 +215,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t
const dataRoot = join(root, 'data'); const dataRoot = join(root, 'data');
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli }); const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
assert.equal((await pinned.inbox()).request.business, 'pinned'); assert.equal((await pinned.inbox()).request.business, 'pinned');
assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
const reader = busReader({ dataRoot, socket: 'sock', cli }); const reader = busReader({ dataRoot, socket: 'sock', cli });
const code = () => reader.agents().then(() => 'resolved', e => e.code); const code = () => reader.agents().then(() => 'resolved', e => e.code);