Manifest pins 9 files on base 4100fa68; candidate source is not committed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
474 lines
24 KiB
Diff
474 lines
24 KiB
Diff
diff --git a/packages/bus/README.md b/packages/bus/README.md
|
|
index 5b57b7fc..a9517a1d 100644
|
|
--- a/packages/bus/README.md
|
|
+++ b/packages/bus/README.md
|
|
@@ -127,6 +127,7 @@ an operator inspect content and delivery evidence after an uncertain result.
|
|
| `launch.revoke`, `launch.restore` | No args; outside-agent human CLI only. |
|
|
| `inbox`, `tasks`, `agents` | No args; plain JSON projections scoped to capability's business. |
|
|
| `trail` | `{subject}`: task ref, decision UUID or message UUID. Linked records in broker write order. |
|
|
+| `credentials` | No args; human and reader sessions only. `[{service,account,role,dateKind,date,state}]` for the capability's business, never a token, file path or variable name. |
|
|
|
|
Unknown keys, roles, references, actions and verbs refuse. If writing refusal
|
|
evidence fails, the caller receives the static `storage-unavailable` code. `human` is the reserved
|
|
@@ -196,7 +197,7 @@ must not invoke the human CLI or work around its proof.
|
|
|
|
Human resolutions and launch toggles append `human.input`. Agent capabilities
|
|
and read-only WebUI capabilities cannot reach these operations. Reader
|
|
-capabilities expose the human inbox plus tasks/agents/trail/decisions for one
|
|
+capabilities expose the human inbox plus tasks/agents/trail/decisions/credentials for one
|
|
business; they do not confer mutation or resolution authority.
|
|
|
|
`decisions` (row 57, #1546) is for human and reader sessions only; a role
|
|
@@ -208,6 +209,26 @@ 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.
|
|
|
|
+`credentials` (row 60, #1549) is also for human and reader sessions only; a
|
|
+role session gets `human-required`. It takes no args. Each row is one
|
|
+credential the running host loaded for the session's business, in load order
|
|
+(role instances, then the tracker sync identity):
|
|
+
|
|
+- `service`: `gitea` or `vikunja`.
|
|
+- `account`: the role's `tracker.bot` for Vikunja, the sync identity's `bot`
|
|
+ for its row, `null` for Gitea.
|
|
+- `role`: the instance, or `tracker sync` for `business/@sync`.
|
|
+- `dateKind` and `date`: `rotateBy` for Gitea, `expires` for Vikunja.
|
|
+- `state`: `credentials.status()` against the host's clock at the request:
|
|
+ `valid`, `expiring` (under 7 days), `expired`, or `rotation-due` for Gitea.
|
|
+
|
|
+The broker gets only that metadata function from the runtime, never
|
|
+`credentials.use`, and the result passes the known-token check like every
|
|
+other read. The rows match the Business view's credentials in the WebUI
|
|
+(`packages/webui/src/reads.mjs`), which reads the business file instead: the
|
|
+file says what the host should load, this read says what it did load, since
|
|
+a token file rotated after startup isn't reread until restart.
|
|
+
|
|
## Shared reads and adapter boundary
|
|
|
|
```js
|
|
diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs
|
|
index 84db8799..18c9bc0d 100644
|
|
--- a/packages/bus/src/broker.mjs
|
|
+++ b/packages/bus/src/broker.mjs
|
|
@@ -86,12 +86,15 @@ export class Broker {
|
|
#runs = new Map();
|
|
#ended = new Set();
|
|
#secretCheck;
|
|
+ #credentialStatus;
|
|
#clock = 0;
|
|
- constructor({ store, businesses, secretCheck = () => {} }) {
|
|
+ // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
|
+ constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [] }) {
|
|
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
|
this.#businesses = structuredClone(businesses);
|
|
this.#store = store;
|
|
this.#secretCheck = secretCheck;
|
|
+ this.#credentialStatus = credentialStatus;
|
|
for (const table of [
|
|
'events',
|
|
'role_claims',
|
|
@@ -468,7 +471,7 @@ export class Broker {
|
|
if (!object(args)) fail('invalid-request');
|
|
this.#secretCheck(args);
|
|
return this.#store.transaction(() => {
|
|
- if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions'].includes(request.verb))
|
|
+ if (s.reader && !['inbox', 'agents', 'tasks', 'trail', 'decisions', 'credentials'].includes(request.verb))
|
|
fail('read-only');
|
|
const result = this.#dispatch(s, request.verb, args);
|
|
this.#secretCheck(result);
|
|
@@ -857,6 +860,28 @@ export class Broker {
|
|
.map((d) => ({ ...this.#decisionView(d), closed: this.#closed(d.id) }));
|
|
fail('invalid-request');
|
|
}
|
|
+ // Row 60 (#1549): what the running host loaded, checked against its clock, for the session's
|
|
+ // business. Service, account, role, date and state only; no token, file path or variable name.
|
|
+ case 'credentials': {
|
|
+ if (!s.human && !s.reader) fail('human-required');
|
|
+ keys(a, [], []);
|
|
+ const prefix = s.business + '/';
|
|
+ return this.#credentialStatus()
|
|
+ .filter((c) => c.instance.startsWith(prefix))
|
|
+ .map(({ instance, service, date, state }) => {
|
|
+ const role = instance.slice(prefix.length),
|
|
+ sync = role === '@sync',
|
|
+ bot = sync ? b.tracker?.sync?.bot : service === 'vikunja' ? b.roles?.[role]?.tracker?.bot : null;
|
|
+ return {
|
|
+ service,
|
|
+ account: bot ?? null,
|
|
+ role: sync ? 'tracker sync' : role,
|
|
+ dateKind: service === 'gitea' ? 'rotateBy' : 'expires',
|
|
+ date,
|
|
+ state,
|
|
+ };
|
|
+ });
|
|
+ }
|
|
default:
|
|
fail('unknown-verb');
|
|
}
|
|
diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs
|
|
index ebc07cf3..e72f65d6 100644
|
|
--- a/packages/bus/src/runtime.mjs
|
|
+++ b/packages/bus/src/runtime.mjs
|
|
@@ -31,7 +31,12 @@ export async function startBroker({
|
|
];
|
|
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot });
|
|
store = new Store(dataRoot);
|
|
- const broker = new Broker({ store, businesses, secretCheck: (value) => credentials.assertClean(value) });
|
|
+ const broker = new Broker({
|
|
+ store,
|
|
+ businesses,
|
|
+ secretCheck: (value) => credentials.assertClean(value),
|
|
+ credentialStatus: () => credentials.status(),
|
|
+ });
|
|
// Process identities must be supplied for every managed run when using the human transport.
|
|
const launchRecords = [];
|
|
function bindLaunch(r) {
|
|
diff --git a/packages/bus/src/views.mjs b/packages/bus/src/views.mjs
|
|
index 2bffda6b..a33c0faf 100644
|
|
--- a/packages/bus/src/views.mjs
|
|
+++ b/packages/bus/src/views.mjs
|
|
@@ -6,5 +6,6 @@ export function views(client) {
|
|
agents: () => client.call('agents'),
|
|
trail: (subject) => client.call('trail', { subject }),
|
|
decisions: (list) => client.call('decisions', { list }),
|
|
+ credentials: () => client.call('credentials'),
|
|
});
|
|
}
|
|
diff --git a/packages/bus/tests/credential-status.test.mjs b/packages/bus/tests/credential-status.test.mjs
|
|
new file mode 100644
|
|
index 00000000..f3f20142
|
|
--- /dev/null
|
|
+++ b/packages/bus/tests/credential-status.test.mjs
|
|
@@ -0,0 +1,212 @@
|
|
+// Row 60 (#1549): `credentials`, the broker's credential status read. Human and reader
|
|
+// sessions read service, account, role, date and state for their own business; a role
|
|
+// session can't. No token, file path or variable name reaches the output or the host log.
|
|
+import test from 'node:test';
|
|
+import assert from 'node:assert/strict';
|
|
+import { fork } from 'node:child_process';
|
|
+import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
+import { tmpdir } from 'node:os';
|
|
+import { join } from 'node:path';
|
|
+import { once } from 'node:events';
|
|
+import { Store } from '../src/store.mjs';
|
|
+import { Broker } from '../src/broker.mjs';
|
|
+import { Credentials } from '../src/credentials.mjs';
|
|
+import { Client } from '../src/client.mjs';
|
|
+
|
|
+const day = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
|
+const authority = { withinRole: ['message.send'], crossRole: [] };
|
|
+const TOKENS = {
|
|
+ pmGitea: 'fixture_pm_gitea_0123456789',
|
|
+ pmVikunja: 'fixture_pm_vikunja_0123456789',
|
|
+ coderGitea: 'fixture_coder_gitea_0123456789',
|
|
+ coderVikunja: 'fixture_coder_vikunja_0123456789',
|
|
+ sync: 'fixture_sync_vikunja_0123456789',
|
|
+ other: 'fixture_other_gitea_0123456789',
|
|
+};
|
|
+const ENV = 'FIXTURE_ROW60_PM_VIKUNJA';
|
|
+
|
|
+// Two businesses, `demo` and `demox`: an instance prefix without its slash would leak one into the other.
|
|
+function seed(root) {
|
|
+ const secrets = join(root, 'secrets');
|
|
+ mkdirSync(secrets);
|
|
+ const file = (name) => {
|
|
+ const path = join(secrets, name);
|
|
+ writeFileSync(path, TOKENS[name] + '\n', { mode: 0o600 });
|
|
+ return path;
|
|
+ };
|
|
+ const paths = { pmGitea: file('pmGitea'), coderGitea: file('coderGitea'), coderVikunja: file('coderVikunja') };
|
|
+ paths.sync = file('sync');
|
|
+ paths.other = file('other');
|
|
+ const businesses = {
|
|
+ demo: {
|
|
+ id: 'demo',
|
|
+ human: 'jason',
|
|
+ arbiters: { technical: 'cto', delivery: 'pm' },
|
|
+ roles: {
|
|
+ pm: {
|
|
+ authority,
|
|
+ tracker: { bot: 'bot-pm' },
|
|
+ credentials: {
|
|
+ gitea: { file: paths.pmGitea, rotateBy: '2099-01-01' },
|
|
+ vikunja: { env: ENV, expires: day(3) },
|
|
+ },
|
|
+ },
|
|
+ coder: {
|
|
+ authority,
|
|
+ tracker: { bot: 'bot-coder' },
|
|
+ credentials: {
|
|
+ gitea: { file: paths.coderGitea, rotateBy: '2000-01-01' },
|
|
+ vikunja: { file: paths.coderVikunja, expires: '2000-01-01' },
|
|
+ },
|
|
+ },
|
|
+ cto: { authority },
|
|
+ },
|
|
+ tracker: { sync: { bot: 'bot-sync', botId: 7, credentials: { vikunja: { file: paths.sync, expires: '2099-01-01' } } } },
|
|
+ },
|
|
+ demox: {
|
|
+ id: 'demox',
|
|
+ human: 'jason',
|
|
+ arbiters: { technical: 'cto', delivery: 'cto' },
|
|
+ roles: { cto: { authority, credentials: { gitea: { file: paths.other, rotateBy: '2099-01-01' } } } },
|
|
+ },
|
|
+ };
|
|
+ return { businesses, paths, secrets, env: { [ENV]: TOKENS.pmVikunja } };
|
|
+}
|
|
+// What the runtime builds: one reference per business/instance, plus business/@sync.
|
|
+function references(businesses) {
|
|
+ const out = {};
|
|
+ for (const [business, b] of Object.entries(businesses)) {
|
|
+ for (const [role, r] of Object.entries(b.roles)) if (r.credentials) out[business + '/' + role] = r.credentials;
|
|
+ if (b.tracker?.sync?.credentials) out[business + '/@sync'] = b.tracker.sync.credentials;
|
|
+ }
|
|
+ return out;
|
|
+}
|
|
+const DEMO = [
|
|
+ { service: 'gitea', account: null, role: 'pm', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
|
|
+ { service: 'vikunja', account: 'bot-pm', role: 'pm', dateKind: 'expires', date: day(3), state: 'expiring' },
|
|
+ { service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: '2000-01-01', state: 'rotation-due' },
|
|
+ { service: 'vikunja', account: 'bot-coder', role: 'coder', dateKind: 'expires', date: '2000-01-01', state: 'expired' },
|
|
+ { service: 'vikunja', account: 'bot-sync', role: 'tracker sync', dateKind: 'expires', date: '2099-01-01', state: 'valid' },
|
|
+];
|
|
+// Nothing that names or holds a credential: the tokens, the token files and their directory, and the env name.
|
|
+const forbidden = (secrets, paths) => [...Object.values(TOKENS), ...Object.values(paths), secrets, ENV];
|
|
+const clean = (text, words) => words.filter((w) => text.includes(w));
|
|
+
|
|
+function setup(t) {
|
|
+ const root = mkdtempSync(join(tmpdir(), 'bus-cred-status-'));
|
|
+ const { businesses, paths, secrets, env } = seed(root);
|
|
+ const credentials = new Credentials({ references: references(businesses), repoRoots: [], dataRoot: '/nonexistent-data', env });
|
|
+ mkdirSync(join(root, 'data'));
|
|
+ const store = new Store(join(root, 'data'));
|
|
+ const b = new Broker({
|
|
+ store,
|
|
+ businesses,
|
|
+ secretCheck: (value) => credentials.assertClean(value),
|
|
+ credentialStatus: () => credentials.status(),
|
|
+ });
|
|
+ t.after(() => {
|
|
+ store.close();
|
|
+ credentials.close();
|
|
+ rmSync(root, { recursive: true, force: true });
|
|
+ });
|
|
+ return { b, secrets, paths };
|
|
+}
|
|
+
|
|
+test('credentials gives human and reader sessions service, account, role, date and state, and nothing else', (t) => {
|
|
+ const { b, secrets, paths } = setup(t);
|
|
+ const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
+ const reader = b.bindReader({ business: 'demo' });
|
|
+ for (const cap of [human, reader]) {
|
|
+ const rows = b.request(cap, { verb: 'credentials' });
|
|
+ assert.deepEqual(rows, DEMO);
|
|
+ for (const row of rows) assert.deepEqual(Object.keys(row), ['service', 'account', 'role', 'dateKind', 'date', 'state']);
|
|
+ assert.deepEqual(clean(JSON.stringify(rows), forbidden(secrets, paths)), []);
|
|
+ }
|
|
+ assert.deepEqual(b.request(b.bindReader({ business: 'demox' }), { verb: 'credentials' }), [
|
|
+ { service: 'gitea', account: null, role: 'cto', dateKind: 'rotateBy', date: '2099-01-01', state: 'valid' },
|
|
+ ]);
|
|
+});
|
|
+
|
|
+test('credentials refuses a role session and any argument', (t) => {
|
|
+ const { b } = setup(t);
|
|
+ const coder = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
|
+ b.request(coder, { verb: 'role.claim' });
|
|
+ assert.throws(() => b.request(coder, { verb: 'credentials' }), { code: 'human-required' });
|
|
+ const reader = b.bindReader({ business: 'demo' });
|
|
+ assert.throws(() => b.request(reader, { verb: 'credentials', args: { role: 'coder' } }), { code: 'invalid-request' });
|
|
+ assert.throws(() => b.request(reader, { verb: 'credentials', args: { business: 'demox' } }), { code: 'invalid-request' });
|
|
+});
|
|
+
|
|
+test('credentials reads the status on each request, and a broker without one lists none', (t) => {
|
|
+ const root = mkdtempSync(join(tmpdir(), 'bus-cred-live-'));
|
|
+ const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'cto' }, roles: { cto: { authority } } } };
|
|
+ const stores = ['a', 'b'].map((d) => (mkdirSync(join(root, d)), new Store(join(root, d))));
|
|
+ t.after(() => {
|
|
+ for (const s of stores) s.close();
|
|
+ rmSync(root, { recursive: true, force: true });
|
|
+ });
|
|
+ let state = 'valid';
|
|
+ const b = new Broker({
|
|
+ store: stores[0],
|
|
+ businesses,
|
|
+ credentialStatus: () => [{ instance: 'demo/cto', service: 'gitea', date: '2099-01-01', state }],
|
|
+ });
|
|
+ const reader = b.bindReader({ business: 'demo' });
|
|
+ assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'valid');
|
|
+ state = 'rotation-due';
|
|
+ assert.equal(b.request(reader, { verb: 'credentials' })[0].state, 'rotation-due');
|
|
+ const bare = new Broker({ store: stores[1], businesses });
|
|
+ assert.deepEqual(bare.request(bare.bindReader({ business: 'demo' }), { verb: 'credentials' }), []);
|
|
+});
|
|
+
|
|
+// The brief's acceptance: the broker process on a seeded config holding a file token, an env
|
|
+// value and their paths and names. The op's output, the host log and the store hold none of them.
|
|
+test('the broker process answers credentials without a token, path or variable in its output, log or store', async (t) => {
|
|
+ const root = mkdtempSync(join(tmpdir(), 'bus-cred-process-'));
|
|
+ t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
+ const { businesses, paths, secrets, env } = seed(root);
|
|
+ const data = join(root, 'data');
|
|
+ mkdirSync(data);
|
|
+ const child = fork(new URL('../src/process.mjs', import.meta.url), [], {
|
|
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
|
+ env: { ...process.env, ...env },
|
|
+ });
|
|
+ let logs = '';
|
|
+ child.stdout.on('data', (b) => (logs += b));
|
|
+ child.stderr.on('data', (b) => (logs += b));
|
|
+ t.after(() => {
|
|
+ if (child.exitCode === null) child.kill('SIGKILL');
|
|
+ });
|
|
+ const reply = once(child, 'message');
|
|
+ child.send({
|
|
+ op: 'boot',
|
|
+ config: {
|
|
+ dataRoot: data,
|
|
+ businesses,
|
|
+ launches: [{ business: 'demo', role: 'coder', run: 'r1', harness: 'pi', pid: process.pid, startTime: '1' }],
|
|
+ readers: ['demo'],
|
|
+ },
|
|
+ });
|
|
+ const ready = (await reply)[0];
|
|
+ assert.equal(ready.ok, true);
|
|
+ const reader = new Client({ path: ready.path, cap: ready.readers[0].cap });
|
|
+ const rows = await reader.call('credentials');
|
|
+ assert.deepEqual(rows, DEMO);
|
|
+ const agent = new Client({ path: ready.path, cap: ready.launches[0].cap });
|
|
+ await agent.call('role.claim');
|
|
+ await assert.rejects(agent.call('credentials'), /human-required/);
|
|
+ const exit = once(child, 'exit');
|
|
+ child.send({ op: 'close' });
|
|
+ assert.equal((await exit)[0], 0);
|
|
+
|
|
+ const words = forbidden(secrets, paths);
|
|
+ assert.deepEqual(clean(JSON.stringify(rows), words), []);
|
|
+ assert.deepEqual(clean(logs, words), []);
|
|
+ const bus = join(data, 'bus');
|
|
+ const stored = readdirSync(bus)
|
|
+ .filter((f) => f.startsWith('bus.sqlite'))
|
|
+ .map((f) => readFileSync(join(bus, f), 'latin1'))
|
|
+ .join('');
|
|
+ assert.ok(stored.includes('human-required'), 'the refusal is in the store');
|
|
+ assert.deepEqual(clean(stored, words), []);
|
|
+});
|
|
diff --git a/packages/webui/README.md b/packages/webui/README.md
|
|
index e43118d5..fedb9195 100644
|
|
--- a/packages/webui/README.md
|
|
+++ b/packages/webui/README.md
|
|
@@ -194,19 +194,23 @@ session reader or transport is implemented here. The session reader is
|
|
`packages/conversation`, served by the board.
|
|
|
|
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
|
|
-`/api/bus/trail?subject=<id>` and `/api/bus/decisions?list=roles|recent` are
|
|
-the bus reads (`src/bus.mjs`). Any other method is 405. The subject must match
|
|
+`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent` and
|
|
+`/api/bus/credentials` are 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.
|
|
+list holds. `credentials` (row 60, #1549) is the bus host's credential
|
|
+status: service, account, role, date and state for each credential it
|
|
+loaded, never a token, file path or variable name. No page uses it yet; the
|
|
+Business view still shows the business file's metadata.
|
|
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
|
|
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,
|
|
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`):
|
|
-the inbox, tasks, agents, trails and the two decision lists. That includes
|
|
+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
|
|
diff --git a/packages/webui/src/bus.mjs b/packages/webui/src/bus.mjs
|
|
index 8fee21a6..ca1bab89 100644
|
|
--- a/packages/webui/src/bus.mjs
|
|
+++ b/packages/webui/src/bus.mjs
|
|
@@ -1,10 +1,10 @@
|
|
// 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
|
|
// with spawn, not spawnSync, so one slow read never stalls the HTTP server.
|
|
-// Only the four verbs of the Q1 module (lead decision 56) and row 57's
|
|
-// `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.
|
|
+// 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.
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
|
|
diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs
|
|
index 4d51942a..c0f10275 100644
|
|
--- a/packages/webui/src/serve.mjs
|
|
+++ b/packages/webui/src/serve.mjs
|
|
@@ -46,8 +46,9 @@ async function body(req) {
|
|
return bytes;
|
|
}
|
|
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
|
-// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs.
|
|
-const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions'];
|
|
+// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
|
+// (#1549) adds `credentials`: what the bus host loaded, metadata only.
|
|
+const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'];
|
|
const DECISION_LISTS = ['roles', 'recent'];
|
|
async function busRead(res, bus, verb, search) {
|
|
let arg;
|
|
diff --git a/packages/webui/tests/bus.test.mjs b/packages/webui/tests/bus.test.mjs
|
|
index 2d9ca6ba..2b429352 100644
|
|
--- a/packages/webui/tests/bus.test.mjs
|
|
+++ b/packages/webui/tests/bus.test.mjs
|
|
@@ -123,15 +123,38 @@ test('decisions route serves the roles and recent lists from the reader view', a
|
|
assert.equal(calls.length, before);
|
|
});
|
|
|
|
+// Row 60 (#1549): what the bus host loaded, metadata only, for the reader's business.
|
|
+test('credentials route serves the broker credential status from the reader view', async t => {
|
|
+ const store = new Store(tmp(t, 'webui-bus-credentials-'));
|
|
+ t.after(() => store.close());
|
|
+ const status = [
|
|
+ { instance: 'demo/coder', service: 'gitea', date: '2000-01-01', state: 'rotation-due' },
|
|
+ { instance: 'other/coder', service: 'gitea', date: '2099-01-01', state: 'valid' },
|
|
+ ];
|
|
+ const b = new Broker({ store, businesses, credentialStatus: () => status });
|
|
+ const reader = b.bindReader({ business: 'demo' });
|
|
+ const calls = [];
|
|
+ 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 });
|
|
+ t.after(() => close(web));
|
|
+ const r = await fetch(`http://127.0.0.1:${web.address().port}/api/bus/credentials`);
|
|
+ assert.equal(r.status, 200); assert.equal(r.headers.get('cache-control'), 'no-store');
|
|
+ const body = await r.json();
|
|
+ assert.match(body.at, /^\d{4}-\d\d-\d\dT/);
|
|
+ assert.deepEqual(body.rows, [{ service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: '2000-01-01', state: 'rotation-due' }]);
|
|
+ assert.deepEqual(calls, [['credentials', {}]]);
|
|
+});
|
|
+
|
|
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 bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials'].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);
|
|
+ assert.equal((await fetch(base + '/api/bus/credentials', { 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']) {
|
|
assert.equal((await fetch(base + path)).status, 404, path);
|
|
@@ -216,6 +239,7 @@ test('busReader takes --business, else the live bus host, else refuses', async t
|
|
const pinned = busReader({ dataRoot, socket: 'sock', business: 'pinned', cli });
|
|
assert.equal((await pinned.inbox()).request.business, 'pinned');
|
|
assert.deepEqual((await pinned.decisions('recent')).request, { business: 'pinned', verb: 'decisions', args: { list: 'recent' } });
|
|
+ assert.deepEqual((await pinned.credentials()).request, { business: 'pinned', verb: 'credentials', args: {} });
|
|
|
|
const reader = busReader({ dataRoot, socket: 'sock', cli });
|
|
const code = () => reader.agents().then(() => 'resolved', e => e.code);
|