feat(console): answer ordinary decisions (#1839, Vikunja #797)

This commit is contained in:
jason.woltje committed 2026-11-03 16:57:16 -06:00
1 parent 7e849a7b0b
commit 7b51dbbba4
10 files changed
+464 -59

No files matched your search

+4
View File
@@ -5979,3 +5979,7 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-03 | Lead Codex, request-to-product design handoff (Vikunja #797, Gitea #1839) | After: docs/plans/2026-11-03_request-to-product-design.md proposes general Console answers first, durable pre-task intake and three modes, typed project bootstrap, project-scoped approved PRDs and automatic continuation through reviewed delivery; original pomodoro is the acceptance case. Opus independent builder check also failed Claude rate limit; lead self-check only, no independent approval. Await owner decision on concrete authority/schema scope and temporary GPT implementation delegation while Claude is unavailable. Documentation only; product behavior and acceptance not claimed. 2026-11-03 | Lead Codex, request-to-product design handoff (Vikunja #797, Gitea #1839) | After: docs/plans/2026-11-03_request-to-product-design.md proposes general Console answers first, durable pre-task intake and three modes, typed project bootstrap, project-scoped approved PRDs and automatic continuation through reviewed delivery; original pomodoro is the acceptance case. Opus independent builder check also failed Claude rate limit; lead self-check only, no independent approval. Await owner decision on concrete authority/schema scope and temporary GPT implementation delegation while Claude is unavailable. Documentation only; product behavior and acceptance not claimed.
2026-11-03 | Lead Codex, #797 / Gitea #1839 implementation start | Jason reports CLI Proxy API leaves Claude accounts unauthenticated and authorizes GPT agents for these tasks. Execute the staged design with GPT coding and independent review; general Console buttons first under existing REQ-WEB-2, detailed lifecycle/PRD plan in parallel. No further Claude calls or production-host changes. 2026-11-03 | Lead Codex, #797 / Gitea #1839 implementation start | Jason reports CLI Proxy API leaves Claude accounts unauthenticated and authorizes GPT agents for these tasks. Execute the staged design with GPT coding and independent review; general Console buttons first under existing REQ-WEB-2, detailed lifecycle/PRD plan in parallel. No further Claude calls or production-host changes.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 before | Prepared checkout t3/b15e72fa at 7e849a7b0b635d34950a5c5c2e4b78c4238fe651. Console answers currently require two-option grant requests. Scope: ordinary 2–9 choices through existing preview/confirmation/resolve, preserved grant and identity gates; no tracker/role/PRD/runtime changes. Baseline bus 12/12; initial WebUI 12 pass/5 fail from missing oauth4webapi@3.8.8, restored with locked npm ci --ignore-scripts, then WebUI 17/17 including Chromium. Intended RED: broker 14 pass/3 fail decision-not-eligible; HTTP/browser 12 pass/5 fail ordinary confirmation 503 or absent option panel; real SSO browser 0 pass/1 fail zero buttons instead of three. Owner continuation asks for user in changed prose, technical identifiers retained for #798.
2026-11-03 | GPT-6.1 Sol implementation sub-agent | #797 / Gitea #1839 stage 1 after | Ordinary user-routed decisions now expose authoritative kind/options through the existing two Console verbs; confirmation uses the selected key/text and ordinary receipts say Recorded. The digest binds the complete decision row and raise authorization/grant context; stale membership/context refuses transactionally. Grant approve/deny/summary and one-use executor path, OIDC/session/origin checks, role attribution and authority remain intact. Cancellation, navigation, reload, closure and changed-context refresh drop UI confirmations without replay. Added real fake-OP signed-in Chromium -> HTTP -> broker third-option coverage: one resolution, verified-subject audit, raiser notice and zero grant activity. RED-to-GREEN also checked project/spec digest binding. Targeted broker 17/17 and WebUI 25/25; final full bus 1407/1407 and WebUI 509/509, zero failures/skips. First full runs: bus 1406 pass/1 fail at unchanged host-run T27 sweep (expected one removed dir, got []), isolated T27 group 4/4 and full rerun green without changing it; WebUI 507 pass/1 fail/1 skip due absent package-local esbuild@0.28.2, restored with its lockfile, isolated build/provenance 7/7 and full rerun green. A newly added SSO test transport initially used nonexistent broker.release; corrected to disconnect after diagnosing its timeout, without weakening assertions. No independent reviewer launched; controller provides review. No tracker, role JSON, PRD, host/service/config/secrets changes, push, merge, PR or deployment. Internal identifier migration remains #798 / Gitea #1840.
+2
View File
@@ -1980,3 +1980,5 @@ are never rewritten or removed; corrections are new entries.
2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | Vikunja #415 and #416, .53 and .54 Console writes | bd79daffd on t3/console-writes-53-54 (base 1ff45c4ad): factory takes the paired --sso --send with a console.sso check before any unit write; .53 business document on provider proxy, route anthropic, account factory/proxy; library --send not done (needs the #385 refusal reverted, returned to the lead); cli case2-unit 88, stack-upgrade 25, case1-accept 53, business 8, 3 and 3, no skips; 2 mutants killed; no push, queue or PR action. 2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | Vikunja #415 and #416, .53 and .54 Console writes | bd79daffd on t3/console-writes-53-54 (base 1ff45c4ad): factory takes the paired --sso --send with a console.sso check before any unit write; .53 business document on provider proxy, route anthropic, account factory/proxy; library --send not done (needs the #385 refusal reverted, returned to the lead); cli case2-unit 88, stack-upgrade 25, case1-accept 53, business 8, 3 and 3, no skips; 2 mutants killed; no push, queue or PR action.
2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | editor-browser paste test round 2 (Vikunja #784) | e71222d1a on t3/784-r2 (base 0c6aa1a1e): test-only; a MutationObserver on the live document records handler-bearing elements entering the page and h.quiet() asserts none, so the CSP filter cannot hide an attach-then-remove; the reviewer's mutation now fails the paste and live-handler tests; the four earlier mutants still killed; editor-browser 17/17, editor 5/5, editor-provenance 6/6, no skips; no push, queue or PR action. 2026-11-03 | Claude (sub-agent of Mosaic Stack lead c5c1e8ba) | editor-browser paste test round 2 (Vikunja #784) | e71222d1a on t3/784-r2 (base 0c6aa1a1e): test-only; a MutationObserver on the live document records handler-bearing elements entering the page and h.quiet() asserts none, so the CSP filter cannot hide an attach-then-remove; the reviewer's mutation now fails the paste and live-handler tests; the four earlier mutants still killed; editor-browser 17/17, editor 5/5, editor-provenance 6/6, no skips; no push, queue or PR action.
2026-11-03 | Lead Codex GPT-6.1 Sol, resumed c5c1e8ba | Jason request-to-product intake, general Console decision buttons and autonomous product workflow (#797, Gitea #1839) | Recovered owner decisions and current code; queue next sage returned historical ledger row 7, direct owner request takes priority; drafted scoped design and acceptance, both Claude research/review delegates failed account rate limit; no product/host/config/role change, no provider substitution; owner alignment and coding-model exception outstanding. 2026-11-03 | Lead Codex GPT-6.1 Sol, resumed c5c1e8ba | Jason request-to-product intake, general Console decision buttons and autonomous product workflow (#797, Gitea #1839) | Recovered owner decisions and current code; queue next sage returned historical ledger row 7, direct owner request takes priority; drafted scoped design and acceptance, both Claude research/review delegates failed account rate limit; no product/host/config/role change, no provider substitution; owner alignment and coding-model exception outstanding.
2026-11-03 | GPT-6.1 Sol implementation sub-agent, prepared t3/b15e72fa | #797 / Gitea #1839 stage 1 ordinary Console answers | 2–9 authoritative choices, selected-option confirmation/receipt, full-context digest and lifecycle coverage; targeted broker 17 and WebUI 25, full bus 1407 and WebUI 509 passing with zero skips; initial unchanged T27 flake and locked dependency restoration recorded in BUILD-LOG; local commit only, controller review pending, no trackers or external state changed.
+19 -15
View File
@@ -1681,17 +1681,21 @@ export class Broker {
['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0) ['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0)
); );
} }
// Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's // #797 / #1839: authoritative answers for a user-routed decision. Open is the caller's check.
// check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding // Ordinary decisions have 2–9 choices. Grant requests keep row 252's narrower eligibility and
// and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is // approve/deny summary; a missing binding never falls through to the ordinary path. The digest
// the other. The summary carries only the binding's method, agent, host and target projects. The // binds the question/action, route, full options and raise-time authorization/grant context.
// digest binds the id, business, options, approval key and binding, so any change reads stale.
#consoleResolution(d) { #consoleResolution(d) {
if (d.route_to !== 'human' || d.action !== 'credential.mint') return null; if (d.route_to !== 'human') return null;
const raise = this.#raised(d.business, d.id); const raise = this.#raised(d.business, d.id);
if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null; if (!raise) return null;
const options = JSON.parse(d.options); const options = JSON.parse(d.options);
if (options.length !== 2 || options[0].key === options[1].key) return null; if (!Array.isArray(options) || options.length < 2 || options.length > 9 ||
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
new Set(options.map(o => o.key)).size !== options.length) return null;
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
const approve = options.find((o) => o.key === raise.approvalChoice); const approve = options.find((o) => o.key === raise.approvalChoice);
if (!approve) return null; if (!approve) return null;
const deny = options.find((o) => o.key !== raise.approvalChoice); const deny = options.find((o) => o.key !== raise.approvalChoice);
@@ -1703,10 +1707,11 @@ export class Broker {
...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}), ...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}),
}; };
return { return {
kind: 'grant', options,
approve: { key: approve.key, text: approve.text }, approve: { key: approve.key, text: approve.text },
deny: { key: deny.key, text: deny.text }, deny: { key: deny.key, text: deny.text },
grant, grant,
digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])), digest,
}; };
} }
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only // r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
@@ -6076,9 +6081,8 @@ export class Broker {
} }
return this.#decisionView(this.#decision(s, did)); return this.#decisionView(this.#decision(s, did));
} }
// Row 252 (#1753, design section 3): the Console's read of one grant request before its // The Console reads authoritative choices and context before confirmation. Ineligible
// confirmation. It returns the two answers, a safe grant summary and the digest the answer must // decisions refuse here, so the browser never decides eligibility.
// carry. Anything not eligible refuses, so the browser never decides eligibility (J1).
case 'decision.preview.console': { case 'decision.preview.console': {
keys(a, ['id'], ['id']); keys(a, ['id'], ['id']);
identifier(a.id); identifier(a.id);
@@ -6100,13 +6104,13 @@ export class Broker {
if (a.note !== undefined) string(a.note); if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id); const d = this.#decision(s, a.id);
if (this.#closed(d.id)) fail('decision-closed'); if (this.#closed(d.id)) fail('decision-closed');
// Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice // Recheck eligibility, context digest and membership in the current options in this
// in this transaction. It answers one of the two keys, never a key the browser names (J2). // transaction, before any resolution, audit, notification or grant execution.
if (viaConsole) { if (viaConsole) {
if (!s.console || s.human !== b.human) fail('human-required'); if (!s.console || s.human !== b.human) fail('human-required');
const c = this.#consoleResolution(d); const c = this.#consoleResolution(d);
if (!c) fail('decision-not-eligible'); if (!c) fail('decision-not-eligible');
if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale'); if (a.digest !== c.digest || !c.options.some(o => o.key === a.choice)) fail('decision-stale');
} else if (verb === 'decision.withdraw') { } else if (verb === 'decision.withdraw') {
if (s.human) { if (s.human) {
this.#human(s); this.#human(s);
+104 -1
View File
@@ -152,7 +152,7 @@ test('preview: the two real answers, a grant summary and the digest, for an open
// The inbox row says the same, for the human and the reader. The agent-facing views do not. // The inbox row says the same, for the human and the reader. The agent-facing views do not.
for (const who of ['human', 'reader']) { for (const who of ['human', 'reader']) {
const row = f.call(who, 'inbox').find((r) => r.id === d.id); const row = f.call(who, 'inbox').find((r) => r.id === d.id);
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest }); assert.deepEqual(row.console, { kind: 'grant', options, approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
} }
}); });
@@ -171,6 +171,109 @@ test('J1, J2: a decision that is not an eligible grant request refuses both cons
} }
}); });
test('ordinary three-choice decisions expose authoritative options and resolve the third choice once with Console provenance', (t) => {
const f = setup(t);
const choices = [
{ key: 'new/autonomous', text: 'New project — autonomous' },
{ key: 'new/guided', text: 'New project — guided' },
{ key: 'existing:project', text: 'Add to existing project' },
];
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'How should we handle this request?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.deepEqual(p.options, choices);
assert.equal(p.kind, 'decision');
assert.equal(p.grant, undefined);
assert.equal(p.approve, undefined);
for (const who of ['human', 'reader']) assert.deepEqual(f.call(who, 'inbox').find(r => r.id === d.id).console.options, choices);
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest });
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: choices[2].key });
assert.deepEqual(f.closes(d.id).map(c => [c.op, c.by, c.choice, c.via]), [['resolved', 'jason', choices[2].key, 'console']]);
const inputs = f.events().filter(e => e.event === 'human.input' && e.about === d.id);
assert.equal(inputs.length, 1);
assert.deepEqual(inputs[0].subject, SUBJECT);
assert.equal(inputs[0].via, 'console');
assert.equal(inputs[0].actor_role, null);
assert.equal(inputs[0].actor_run, null);
assert.equal(inputs[0].decisionSeq, f.closes(d.id)[0].seq);
const notices = f.store.all("SELECT * FROM messages WHERE decision=? AND from_role='human' AND class='DECISION'", d.id);
assert.equal(notices.length, 1);
assert.equal(notices[0].to_role, 'chief');
assert.equal(notices[0].from_run, 'human-console');
assert.match(notices[0].body, /resolved by jason: existing:project \(Add to existing project\)/);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[2].key, digest: p.digest }), 'decision-closed');
assert.equal(f.enrolls.length, 0);
assert.deepEqual(f.grantEvents(d.id), []);
assert.deepEqual(f.make().reconcileGrants(), []);
});
test('ordinary PRD approval and nine non-yes/no choices use the same protocol without grant execution', (t) => {
const f = setup(t);
for (const choices of [options, Array.from({ length: 9 }, (_, i) => ({ key: `choice-${i}`, text: `Choice ${i}` }))]) {
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', question: 'Approve this PRD?', options: choices, recommendation: choices[0].key, approvalChoice: choices[0].key, blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.deepEqual(p.options, choices);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'outside', digest: p.digest }), 'decision-stale');
assert.equal(f.closes(d.id).length, 0);
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: d.id, choice: choices[0].key, digest: p.digest }), { id: d.id, op: 'resolved', choice: choices[0].key });
assert.deepEqual(f.grantEvents(d.id), []);
}
assert.equal(f.enrolls.length, 0);
});
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
const f = setup(t);
for (const route of ['pm', 'chief']) {
const id = grantDecision(f.store, { action: 'prd.approve', route, close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
f.call(route, 'decision.resolve', { id, choice: 'yes' });
assert.deepEqual(f.closes(id).map(c => [c.by, c.via]), [[route + '-run', 'broker']]);
assert.equal(f.events().filter(e => e.event === 'human.input' && e.about === id).length, 0);
}
});
test('the digest binds ordinary question, action, options, business and raise authorization context', (t) => {
const f = setup(t);
const d = f.call('chief', 'decision.raise', { action: 'prd.approve', target: 'product-1', question: 'Approve draft?', options, recommendation: 'yes', blocking: false });
const p = f.call('console', 'decision.preview.console', { id: d.id });
const original = f.store.get('SELECT * FROM decisions WHERE id=?', d.id);
const event = f.store.get("SELECT * FROM events WHERE subject=? AND kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise'", d.id);
// Each immutable variant is inserted into a fresh scratch store, never rewritten in place.
for (const change of [
{ question: 'Approve changed draft?' }, { action: 'deploy' },
{ project: 'product-2' }, { spec_commit: 'f'.repeat(40) },
{ options: JSON.stringify([{ key: 'yes', text: 'Changed' }, options[1]]) },
{ authorization: { target: 'product-2' } }, { authorization: { approvalChoice: 'no' } },
]) {
const g = setup(t), { authorization, ...fields } = change;
const row = { ...original, ...fields };
const columns = Object.keys(row);
g.store.run(`INSERT INTO decisions(${columns.join(',')}) VALUES(${columns.map(() => '?').join(',')})`, ...Object.values(row));
const e = { ...event, body: JSON.stringify({ ...JSON.parse(event.body), ...authorization }) };
const ec = Object.keys(e);
g.store.run(`INSERT INTO events(${ec.join(',')}) VALUES(${ec.map(() => '?').join(',')})`, ...Object.values(e));
const current = g.call('console', 'decision.preview.console', { id: d.id });
assert.notEqual(current.digest, p.digest);
refusesSync(() => g.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: p.digest }), 'decision-stale');
assert.deepEqual(g.closes(d.id), []);
}
const other = grantDecision(f.store, { business: 'other', action: 'prd.approve', close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id: other }), 'decision-not-found');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: other, choice: 'yes', digest: p.digest }), 'decision-not-found');
});
test('missing and malformed grant bindings cannot become ordinary Console answers or start an executor', (t) => {
const f = setup(t);
for (const grant of [undefined, {}, { host: 'web1' }, { method: 7 }]) {
const id = grantDecision(f.store, { grant, close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
assert.deepEqual(f.closes(id), []);
assert.deepEqual(f.grantEvents(id), []);
}
assert.equal(f.enrolls.length, 0);
});
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => { test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
const f = setup(t); const f = setup(t);
const d = await f.raise(); const d = await f.raise();
+20 -10
View File
@@ -328,28 +328,38 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
same functions, so both show the same broker data. same functions, so both show the same broker data.
- Nothing writes but the "Message a role" box, the Settings update, the Assistant - Nothing writes but the "Message a role" box, the Settings update, the Assistant
section's three buttons and the spec and docs editor's Save, all under `--send` (above, section's three buttons and the spec and docs editor's Save, all under `--send` (above,
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below. "Assistant settings" and "Spec and docs editor"), and the decision answers below.
A decision shows `mosaic decide <id> <key>` for each choice, 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 with Copy, which only puts the command on the clipboard. This optional terminal
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The fallback remains available (REQ-DEC-3, Q4). Seen is not set from the browser. The
command has no `--business`, so `mosaic decide` uses the running bus host's command has no `--business`, so `mosaic decide` uses the running bus host's
business, as the Console does by default. From a Console started with business, as the Console does by default. From a Console started with
`--business` for another business the copied command refuses (no bus host, `--business` for another business the copied command refuses (no bus host,
or no such decision) rather than answering elsewhere. or no such decision) rather than answering elsewhere.
- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed - #797 / Gitea #1839 stage 1 (REQ-WEB-2), extending row 252 (#1753): an open
to the human, raised with a grant and two options) can also be approved or decision routed to the user offers one answer button per option, including
denied on its decision page, only when the Console runs with both `--sso` and ordinary two-to-nine-option decisions with non-yes/no keys. A grant request
(`credential.mint`, raised with a grant and two options) retains Approve and
Deny with its concrete rights summary. Answers are enabled only with `--sso` and
`--send` and the page is signed in as a `console.sso` subject. The first click `--send` and the page is signed in as a `console.sso` subject. The first click
POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only
Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to
the session, decision and choice). Each step spawns the session, business, decision, choice and current context digest). The broker
supplies authoritative `options` and a `kind` of `decision` or `grant`; grants
also retain `approve`, `deny` and `grant`. Ordinary HTTP confirmations carry
`kind: decision`, `action: answer` and the selected key/text, so the receipt
records that choice without claiming a grant started. Each step spawns
`packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's `packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's
child holding the signed-in subject, and the bus records the answer `via child holding the signed-in subject, and the bus records the answer `via
console` with a `human.input` naming that subject. Nothing retries; an unknown console` with a `human.input` naming that subject. Nothing retries; an unknown
outcome says to refresh. Without `--sso` the page has no buttons and says why; outcome says to refresh. Cancellation, navigation and page reload discard the
page's confirmation; a refresh that finds a closed decision or a changed
context drops it too. The broker rechecks eligibility, digest and choice in
the resolve transaction. Missing or invalid grant bindings cannot fall through
to ordinary answers. Without `--sso` the page has no buttons and says why;
under `--sso` without `--send` it says the Console is read-only for decisions. under `--sso` without `--send` it says the Console is read-only for decisions.
The tester listener has no decision routes. Every other decision stays The tester listener has no decision routes. Decisions routed to agent roles
terminal-only. remain with those roles, using their real role/run attribution.
- What no Q1 verb returns is labelled where it would show, for example - What no Q1 verb returns is labelled where it would show, for example
"Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed. "Bot names: not in the Q1 module" (lead decision 63). Nothing is guessed.
- A task's list row can only say its current state came from a Vikunja read, - A task's list row can only say its current state came from a Vikunja read,
+20 -10
View File
@@ -1,6 +1,6 @@
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant // #797 / #1839 extends row 252's signed-in Console answers to ordinary decisions. Two POSTs.
// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's // /confirmation asks the bus for authoritative answers and digest and returns a one-use
// two answers and digest and returns a one-use confirmation bound to this session, business, // confirmation bound to this session, business,
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the // decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus // confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
// rechecks everything in the resolve transaction. Nothing here retries. // rechecks everything in the resolve transaction. Nothing here retries.
@@ -89,10 +89,17 @@ async function readBody(req) {
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' }; return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
} catch { return { code: 'invalid-request' }; } } catch { return { code: 'invalid-request' }; }
} }
const preview = (v, id) => const option = o => o && typeof o.key === 'string' && o.key.length > 0 && typeof o.text === 'string' && o.text.length > 0;
v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') && const options = list => Array.isArray(list) && list.length >= 2 && list.length <= 9 && list.every(option) && new Set(list.map(o => o.key)).size === list.length;
['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') && function preview(v, id) {
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string'; if (!v || v.id !== id || typeof v.business !== 'string' || !DIGEST.test(v.digest ?? '')) return false;
if (v.kind === 'decision') return options(v.options) && !['grant', 'approve', 'deny'].some(k => k in v);
// Retain the existing grant preview fields, including previews from an older pinned broker.
return (v.kind === undefined || v.kind === 'grant') && option(v.approve) && option(v.deny) &&
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string' &&
(v.options === undefined || (options(v.options) && v.options.length === 2 &&
[v.approve, v.deny].every(o => v.options.some(x => x.key === o.key && x.text === o.text))));
}
// `session` is the live sign-in session object this request passed, and `live()` reads it again // `session` is the live sign-in session object this request passed, and `live()` reads it again
// right before each spawn. `call(verb, args, { subject, business })` is the console transport. // right before each spawn. `call(verb, args, { subject, business })` is the console transport.
@@ -120,13 +127,16 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
let v; let v;
try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); } try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
if (!preview(v, id)) return refuse('confirmation-unavailable'); if (!preview(v, id)) return refuse('confirmation-unavailable');
const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null; const ordinary = v.kind === 'decision';
if (!action) return refuse('decision-stale'); const chosen = (ordinary ? v.options : [v.approve, v.deny]).find(o => o.key === value.choice);
if (!chosen) return refuse('decision-stale');
const action = ordinary ? 'answer' : value.choice === v.approve.key ? 'approve' : 'deny';
if (live() !== session) return refuse('sign-in-required'); if (live() !== session) return refuse('sign-in-required');
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest }); const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest });
if (!confirmation) return refuse('confirmation-unavailable'); if (!confirmation) return refuse('confirmation-unavailable');
line(`action=${action}`); line(`action=${action}`);
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation }); return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: chosen.text,
...(ordinary ? { kind: 'decision' } : { grant: v.grant }), confirmation });
} }
const keys = Object.keys(value); const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request'); if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
+31 -19
View File
@@ -258,7 +258,7 @@
// Inbox // Inbox
function decisionCard(d) { function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div> return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}</p> <p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(d.console.kind === 'decision' ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
<p class="small s1-auth">${approves(d.authorization)}</p> <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>`; <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>`;
} }
@@ -316,7 +316,7 @@
el.outerHTML = composerHtml(); el.outerHTML = composerHtml();
restore(k); restore(k);
} }
// Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when // #797 / #1839 extends row 252's answer panel to every eligible option, drawn only when
// the server says this Console answers decisions (`decide` in /api/config: signed in through SSO // the server says this Console answers decisions (`decide` in /api/config: signed in through SSO
// and started with --send) and the bus marked the inbox row eligible (its `console` field). The // and started with --send) and the bus marked the inbox row eligible (its `console` field). The
// first click asks the Console for a one-use confirmation that names what the answer does. Only // first click asks the Console for a one-use confirmation that names what the answer does. Only
@@ -324,7 +324,11 @@
// unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s // unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s
// refresh redraws it. The server and the bus check everything again; this is presentation only. // refresh redraws it. The server and the bus check everything again; this is presentation only.
let canDecide = false, ssoOn = false, answer = null; let canDecide = false, ssoOn = false, answer = null;
document.addEventListener('mosaic-signed-out', () => { answer = null; });
const ACTIONS = { approve: 'Approve', deny: 'Deny' }; const ACTIONS = { approve: 'Approve', deny: 'Deny' };
const answerChoices = c => c.kind === 'decision'
? c.options.map((o, i) => ({ ...o, step: `option-${i}`, label: o.text }))
: Object.entries(ACTIONS).map(([step, label]) => ({ ...c[step], step, label }));
const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · '); const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · ');
function decideHtml() { function decideHtml() {
const a = answer; const a = answer;
@@ -333,17 +337,18 @@
let body; let body;
if (a.result) { if (a.result) {
const r = a.result; const r = a.result;
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.</p>`; body = `<p class="receipt" id="decide-status" tabindex="-1">${r.kind === 'decision' ? 'Recorded' : r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}.${r.kind === 'decision' ? '' : ` ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'}`} History below shows it once the bus is read again.</p>`;
} else if (a.confirm) { } else if (a.confirm) {
const c = a.confirm, label = `Confirm ${c.action}`; const c = a.confirm, label = `Confirm ${c.action}`;
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3> body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd><dt>Grant</dt><dd>${grantText(c.grant)}</dd></dl> <dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd>${c.kind === 'decision' ? '' : `<dt>Grant</dt><dd>${grantText(c.grant)}</dd>`}</dl>
<p class="small muted">${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p> <p class="small muted">${c.kind === 'decision' ? 'Confirm to record this choice.' : c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`; <div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
} else { } else {
body = `<ul class="s1-plain">${Object.entries(ACTIONS).map(([k, l]) => `<li><b>${l}</b> chooses <b class="s1-key">${txt(a.console[k].key)}</b> ${txt(a.console[k].text)}</li>`).join('')}</ul> const choices = answerChoices(a.console);
<p class="small muted">Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.</p> body = `<ul class="s1-plain">${choices.map(o => `<li>${a.console.kind === 'decision' ? '' : `<b>${txt(o.label)}</b> chooses `}<b class="s1-key">${txt(o.key)}</b> ${txt(o.text)}</li>`).join('')}</ul>
<div class="s1-cmd">${Object.entries(ACTIONS).map(([k, l]) => `<button type="button" class="btn${k === 'approve' ? ' primary' : ''}" data-decide="${k}"${off}>${a.pending === k ? 'Checking…' : l}</button>`).join('')}</div>`; <p class="small muted">${a.console.kind === 'decision' ? '' : `Grant: ${grantText(a.console.grant)}. `}Each button opens a confirmation first. Nothing is answered until you confirm.</p>
<div class="s1-cmd">${choices.map(o => `<button type="button" class="btn${o.step === 'approve' ? ' primary' : ''}" data-decide="${o.step}"${off}>${a.pending === o.step ? 'Checking…' : txt(o.label)}</button>`).join('')}</div>`;
} }
const e = a.error; const e = a.error;
const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : ''; const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : '';
@@ -375,18 +380,20 @@
async function decide(step) { async function decide(step) {
const a = answer; const a = answer;
if (!a || a.pending || a.result) return; if (!a || a.pending || a.result) return;
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; } if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide]'); announce('Cancelled. Nothing was answered.'); return; }
if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; } if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; }
if (step === 'approve' || step === 'deny') { const selected = answerChoices(a.console).find(o => o.step === step);
const choice = a.console[step].key; if (selected) {
const choice = selected.key, ordinary = a.console.kind === 'decision';
a.pending = step; a.error = null; a.confirm = null; paintDecide(); a.pending = step; a.error = null; a.confirm = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice }); const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice });
a.pending = null; a.pending = null;
// The page moved to another decision meanwhile: its confirmation is dropped, never shown. // The page moved to another decision meanwhile: its confirmation is dropped, never shown.
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; } if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; } if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') { if (res?.ok && json?.id === a.id && json.action === (ordinary ? 'answer' : step) && json.choice === choice && typeof json.text === 'string' && typeof json.confirmation === 'string' &&
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`); (ordinary ? json.kind === 'decision' && !json.grant : json.grant && typeof json.grant.method === 'string')) {
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${json.action}: ${json.text}`);
} else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } } else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
return; return;
} }
@@ -399,8 +406,8 @@
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; } if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; } if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) { if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null }; a.result = { kind: c.kind, action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false); paintDecide('#decide-status'); announce(c.kind === 'decision' ? 'Recorded' : c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); } } else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
} }
async function inboxView(get) { async function inboxView(get) {
@@ -408,7 +415,7 @@
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at))); const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows); 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.'); 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.');
const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with <code>mosaic decide</code>.' const answered = canDecide ? 'Open an eligible decision to answer any of its options here, after a confirmation. Grant requests keep their Approve and Deny confirmation. You can also use <code>mosaic decide</code> in a terminal.'
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.'; : 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
return `${head('Inbox', `${readNote()} ${answered}`)} return `${head('Inbox', `${readNote()} ${answered}`)}
${await composer(get)} ${await composer(get)}
@@ -439,7 +446,10 @@
// or this page just answered it. // or this page just answered it.
const eligible = canDecide && answerable && open?.console ? open.console : null; const eligible = canDecide && answerable && open?.console ? open.console : null;
if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null; if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible; else if (eligible && !answer.pending) {
if (answer.console.digest !== eligible.digest) answer.confirm = null;
answer.console = eligible;
}
else if (!eligible && !answer.pending && !answer.result) answer = null; else if (!eligible && !answer.pending && !answer.result) answer = null;
const options = `<ol class="s1-opts">${d.options.map(o => { const options = `<ol class="s1-opts">${d.options.map(o => {
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key; const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
@@ -454,8 +464,8 @@
// draws a disabled one (design section 5). // draws a disabled one (design section 5).
const why = !answerable || isTester || canDecide ? '' const why = !answerable || isTester || canDecide ? ''
: ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.' : ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.'
: ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).'; : ' Answering a decision from the Console needs sign-in (--sso) and writes enabled (--send).';
const help = eligible ? '<p class="small muted">Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.</p>' const help = eligible ? '<p class="small muted">Choose an answer in the panel above. You can also run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>` : answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : ''; : !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)} return `${head(`Decision ${txt(short(d.id))}`, status)}
@@ -1231,6 +1241,8 @@
clearTimeout(timer); clearTimeout(timer);
if (out()) return; if (out()) return;
const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === ''; const route = routeOf(), [path, query = ''] = route.split('?'), board = path === '/' || path === '';
// Leaving a decision discards its confirmation immediately, including a preview in flight.
if (answer && (navigated || path !== `/inbox/${answer.id}`)) answer = null;
const g = ++gen; const g = ++gen;
document.body.classList.toggle('on-bus', !board); document.body.classList.toggle('on-bus', !board);
$('bus-view').hidden = board; $('bus-view').hidden = board;
+127 -3
View File
@@ -36,6 +36,11 @@ const STUB = `(() => {
const c = await (await real(url, init)).json(); const c = await (await real(url, init)).json();
return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) }); return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) });
} }
if (u === '/api/bus/inbox' && window.__changedDecision) {
const body = await (await real(url, init)).json();
body.rows = body.rows.map(row => row.id === window.__changedDecision ? { ...row, console: { ...row.console, digest: 'e'.repeat(64) } } : row);
return answer(200, body);
}
if (!u.startsWith('/api/decisions/')) return real(url, init); if (!u.startsWith('/api/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect }); window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect });
const a = window.__answers.shift(); const a = window.__answers.shift();
@@ -64,13 +69,20 @@ function hostileGrant(store, grant) {
return id; return id;
} }
async function setup() { async function setup({ ordinary = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-')); const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses }); const store = new Store(root), b0 = new Broker({ store, businesses });
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] }; const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
const open = hostileGrant(store, grant); const open = hostileGrant(store, grant);
const deploy = grantDecision(store, { action: 'deploy', grant, close: null }); const deploy = grantDecision(store, { action: 'deploy', grant, close: null });
const bare = grantDecision(store, { close: null }); const bare = grantDecision(store, { close: null });
const chief = b0.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
b0.request(chief, { verb: 'role.claim' });
const normal = ordinary ? b0.request(chief, { verb: 'decision.raise', args: {
action: 'prd.approve', question: `Choose a project ${HOSTILE}`, blocking: false,
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: `Add to existing project ${HOSTILE}` }],
recommendation: 'new/autonomous', approvalChoice: 'new/autonomous',
} }).id : null;
const reader = b0.bindReader({ business: 'demo' }); const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser(); const f = await fixture(), b = await browser();
@@ -93,7 +105,7 @@ async function setup() {
}; };
const settle = () => new Promise(r => setTimeout(r, 600)); const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); }; const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done }; return { b, store, base, open, deploy, bare, normal, wait, text, has, posts, queue, click, page, settle, done };
} }
const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } }); const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } });
@@ -135,7 +147,7 @@ test('J16: no controls without sso and decide, read-only reasons, terminal lines
await s.b.navigate(`${s.base}?sso&decide#/inbox`); await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3'); await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3');
await s.settle(); await s.settle();
assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./); assert.match(await s.text('#bus-view'), /Open an eligible decision to answer any of its options here, after a confirmation\./);
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1); assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1);
assert.deepEqual(await s.posts(), []); assert.deepEqual(await s.posts(), []);
} finally { await s.done(); } } finally { await s.done(); }
@@ -248,3 +260,115 @@ test('a sign-in redirect at either step signs the page out and answers nothing',
assert.equal((await s.posts()).length, 2); assert.equal((await s.posts()).length, 2);
} finally { await s.done(); } } finally { await s.done(); }
}); });
test('ordinary choices render every key/text, cancel and navigation discard confirmations, and the third option records an accurate receipt', { timeout: 120000 }, async () => {
const s = await setup({ ordinary: true });
const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: `Choose a project ${HOSTILE}`, choice: 'existing:project', text: `Add to existing project ${HOSTILE}`, confirmation: 'd'.repeat(64) } };
try {
await s.page('sso&decide', s.normal);
assert.equal(await s.has('#decide-section'), true);
assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => e.textContent)'), ['New project — autonomous', 'New project — guided', `Add to existing project ${HOSTILE}`]);
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
assert.doesNotMatch(await s.text('#decide-section'), /Grant:|Approve|Deny/);
await s.queue({ ...confirmation, hold: true });
await s.click('option-2');
await s.click('option-2');
await s.b.evaluate('window.__release(), true');
await s.wait('document.querySelector("[data-decide=confirm]")');
assert.equal((await s.posts()).length, 1);
assert.match(await s.text('.s1-confirm'), /Confirm answer/);
assert.doesNotMatch(await s.text('.s1-confirm'), /Grant|Approving|Denying/);
await s.settle();
assert.equal(await s.has('[data-decide=confirm]'), true);
await s.click('cancel');
assert.equal(await s.has('[data-decide=confirm]'), false);
assert.equal((await s.posts()).length, 1);
await s.queue(confirmation);
await s.click('option-2');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")');
await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`);
await s.wait('document.querySelector("#decide-section")');
assert.equal(await s.has('[data-decide=confirm]'), false, 'leaving the decision must discard its confirmation');
assert.equal((await s.posts()).length, 2);
// Navigation while the preview is in flight must also discard the late answer.
await s.queue({ ...confirmation, hold: true });
await s.click('option-2');
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox")');
await s.b.evaluate('window.__release(), true');
await s.b.navigate(`${s.base}?sso&decide#/inbox/${s.normal}`);
await s.wait('document.querySelector("#decide-section")');
assert.equal(await s.has('[data-decide=confirm]'), false);
await s.queue(confirmation);
await s.click('option-2');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.queue({ status: 200, hold: true, body: { id: s.normal, op: 'resolved', choice: 'existing:project' } });
await s.click('confirm');
await s.click('confirm');
await s.b.evaluate('window.__release(), true');
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
assert.match(await s.text('#decide-status'), /Recorded: you chose “existing:project”, Add to existing project/);
assert.doesNotMatch(await s.text('#decide-status'), /grant|denial|Approved|Denied|Nothing starts/);
await s.settle();
const posts = await s.posts();
assert.equal(posts.filter(p => p.url.endsWith('/resolve')).length, 1);
assert.equal(await s.has('[data-decide]'), false);
} finally { await s.done(); }
});
test('ordinary confirmation is dropped on closure, reload, session expiry and uncertain outcome without automatic replay', { timeout: 120000 }, async () => {
const s = await setup({ ordinary: true });
const confirmation = { status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/guided', text: 'New project — guided', confirmation: 'd'.repeat(64) } };
try {
await s.page('sso&decide', s.normal);
assert.equal(await s.has('#decide-section'), true);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.page('sso&decide', s.normal);
assert.equal(await s.has('[data-decide=confirm]'), false);
assert.equal((await s.posts()).length, 0);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.queue({ reject: true });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
await s.settle();
assert.equal((await s.posts()).length, 2);
assert.equal(await s.has('[data-decide=confirm]'), false);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.queue({ status: 401, body: { error: 'sign-in-required', message: 'Sign in again before answering.' } });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status code")?.textContent === "sign-in-required" || document.body.classList.contains("signed-out")');
assert.equal(await s.has('[data-decide=confirm]'), false);
assert.equal((await s.posts()).length, 4);
await s.page('sso&decide', s.normal);
await s.queue(confirmation);
await s.click('option-1');
await s.wait('document.querySelector("[data-decide=confirm]")');
s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.normal, '2026-11-03T00:00:00.000Z', 'resolved', 'jason', 'new/autonomous', 'cli');
await s.wait('!document.querySelector("#decide-section")');
assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0);
} finally { await s.done(); }
});
test('a changed authoritative context drops an open ordinary confirmation before another answer can be submitted', { timeout: 120000 }, async () => {
const s = await setup({ ordinary: true });
try {
await s.page('sso&decide', s.normal);
await s.queue({ status: 200, body: { id: s.normal, kind: 'decision', action: 'answer', question: 'Choose a project', choice: 'new/autonomous', text: 'New project — autonomous', confirmation: 'c'.repeat(64) } });
await s.click('option-0');
await s.wait('document.querySelector("[data-decide=confirm]")');
await s.b.evaluate(`window.__changedDecision = ${JSON.stringify(s.normal)}`);
await s.wait('!document.querySelector("[data-decide=confirm]") && document.querySelector("[data-decide=option-0]")');
assert.equal((await s.posts()).length, 1);
assert.equal((await s.posts()).filter(p => p.url.endsWith('/resolve')).length, 0);
} finally { await s.done(); }
});
+76 -1
View File
@@ -7,7 +7,13 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { request } from 'node:http'; import { request } from 'node:http';
import { readFileSync } from 'node:fs'; import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../../bus/src/broker.mjs';
import { Store } from '../../bus/src/store.mjs';
import { views } from '../../bus/src/views.mjs';
import { browser } from './browser.mjs';
import { startServer } from '../src/serve.mjs'; import { startServer } from '../src/serve.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs'; import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { DECISION_REFUSALS } from '../src/decision-http.mjs'; import { DECISION_REFUSALS } from '../src/decision-http.mjs';
@@ -96,6 +102,75 @@ test('a signed-in owner confirms and answers, with the session\'s provider, issu
assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false); assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false);
}); });
test('a signed-in browser selects the third ordinary option through HTTP and records exactly one audited resolution and raiser notification, with zero grants', { timeout: 120000 }, async t => {
const root = mkdtempSync(join(tmpdir(), 'console-797-sso-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'demo', human: 'jason', arbiters: { delivery: 'pm', technical: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } };
let grants = 0;
const broker = new Broker({ store, businesses: { demo: business }, hostGrant: { enroll: async () => { grants++; } } });
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
broker.request(chief, { verb: 'role.claim' });
const d = broker.request(chief, { verb: 'decision.raise', args: { action: 'prd.approve', question: 'Choose a product project', blocking: false,
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }], recommendation: 'new/autonomous', approvalChoice: 'new/autonomous' } });
const reader = broker.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => broker.request(reader, { verb, args }) });
const calls = [];
const env = await setup(t, { bus, decisionWrite: async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
const cap = broker.bindConsole({ business: ctx.business ?? 'demo', human: 'jason', subject: ctx.subject });
try { return broker.request(cap, { verb, args }); } finally { broker.disconnect(cap); }
} });
const session = await env.signIn();
const b = await browser();
t.after(() => b.close());
// Existing CDP harness pattern: fulfill a synthetic HTTPS origin from this scratch listener,
// carrying the real fake-OP session just established above; no live Console or proxy is used.
b.on('Fetch.requestPaused', ({ requestId, request: incoming }) => {
const url = new URL(incoming.url);
const req = request({ host: '127.0.0.1', port: env.port, path: url.pathname + url.search, method: incoming.method,
headers: { ...incoming.headers, host: HOST, cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => {
const parts = [];
res.on('data', part => parts.push(part));
res.on('end', () => {
const responseHeaders = Object.entries(res.headers).filter(([name]) => !['connection', 'keep-alive', 'transfer-encoding'].includes(name))
.flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map(value => ({ name, value: String(value) })));
b.call('Fetch.fulfillRequest', { requestId, responseCode: res.statusCode, responseHeaders, body: Buffer.concat(parts).toString('base64') }).catch(() => {});
});
});
req.on('error', () => b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {}));
req.end(incoming.postData);
});
await b.call('Fetch.enable', { patterns: [{ urlPattern: ORIGIN + '/*', requestStage: 'Request' }] });
await b.navigate(`${ORIGIN}/#/inbox/${d.id}`);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,50))}throw new Error('timed out')})()`);
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && document.querySelector("#bus-view .s1-opts")');
assert.equal(await b.evaluate('document.querySelectorAll("#decide-section [data-decide]").length'), 3);
await b.evaluate('document.querySelector("[data-decide=option-2]").click()');
await wait('document.querySelector("[data-decide=confirm]")');
assert.equal(store.get("SELECT count(*) AS n FROM decision_events WHERE decision=? AND op='resolved'", d.id).n, 0);
await b.evaluate('document.querySelector("[data-decide=confirm]").click(); document.querySelector("[data-decide=confirm]")?.click()');
await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
assert.match(await b.evaluate('document.querySelector("#decide-status").textContent'), /existing:project.*Add to existing project/);
assert.doesNotMatch(await b.evaluate('document.querySelector("#decide-status").textContent'), /grant|denial|Approved|Denied/);
const closes = store.all('SELECT * FROM decision_events WHERE decision=?', d.id);
assert.equal(closes.length, 1);
assert.deepEqual([closes[0].op, closes[0].by, closes[0].choice, closes[0].via], ['resolved', 'jason', 'existing:project', 'console']);
const inputs = store.all("SELECT * FROM events WHERE kind='human.input' AND subject=?", d.id);
assert.equal(inputs.length, 1);
const input = JSON.parse(inputs[0].body);
assert.deepEqual(input.subject, { provider: 'a', iss: env.iss.issuer, sub: 'user-a' });
assert.equal(input.via, 'console');
assert.equal(input.decisionSeq, closes[0].seq);
const notices = store.all("SELECT * FROM messages WHERE decision=? AND class='DECISION' AND from_role='human'", d.id);
assert.equal(notices.length, 1);
assert.equal(notices[0].to_role, 'chief');
assert.equal(notices[0].from_run, 'human-console');
assert.equal(calls.filter(c => c.verb === 'decision.resolve.console').length, 1);
assert.equal(grants, 0);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE actor_run='grant'").n, 0);
});
test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => { test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => {
const env = await setup(t); const env = await setup(t);
refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required'); refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required');
@@ -86,6 +86,67 @@ test('deny resolves without a grant field', async () => {
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' }); assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
}); });
const ORDINARY = {
id: 'd1', business: 'demo', question: 'How should we handle this request?', digest: DIGEST, kind: 'decision',
options: [{ key: 'new/autonomous', text: 'New project — autonomous' }, { key: 'new/guided', text: 'New project — guided' }, { key: 'existing:project', text: 'Add to existing project' }],
};
test('ordinary confirmation carries the selected third key/text and decision context, then resolves once in its business', async () => {
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
const c = await s.confirm('existing:project');
assert.equal(c.status, 200, c.text);
assert.deepEqual({ ...c.json(), confirmation: 'x' }, { id: 'd1', kind: 'decision', question: ORDINARY.question, action: 'answer', choice: 'existing:project', text: 'Add to existing project', confirmation: 'x' });
const body = { choice: 'existing:project', confirmation: c.json().confirmation };
assert.deepEqual((await s.resolve(body)).json(), { id: 'd1', op: 'resolved', choice: 'existing:project' });
assert.equal(s.calls[1].ctx.business, 'demo');
assert.equal(s.calls[1].args.digest, DIGEST);
refused(await s.resolve(body), 'confirmation-required');
assert.equal(s.calls.length, 2);
});
test('ordinary options are validated authoritatively at confirmation, including two and nine choices', async () => {
for (const options of [ORDINARY.options.slice(0, 2), Array.from({ length: 9 }, (_, i) => ({ key: `key:${i}`, text: `Answer ${i}` }))]) {
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
assert.equal((await s.confirm(options.at(-1).key)).status, 200);
refused(await s.confirm('not-listed'), 'decision-stale');
}
for (const options of [[], ORDINARY.options.slice(0, 1), Array(10).fill(ORDINARY.options[0]), [ORDINARY.options[0], ORDINARY.options[0]], [{ key: 1, text: 'x' }, ORDINARY.options[1]], [{ key: 'x' }, ORDINARY.options[1]]]) {
const s = setup({ answer: () => ({ ...ORDINARY, options }) });
refused(await s.confirm('new/autonomous'), 'confirmation-unavailable');
assert.equal(s.confirmations.size, 0);
}
});
test('ordinary stale/closed and uncertain outcomes spend their confirmation and never replay', async () => {
for (const code of ['decision-stale', 'decision-closed', 'outcome-unknown']) {
const s = setup({ answer: () => ORDINARY, resolve: () => { throw new BusReadError(code); } });
const c = await s.confirm('new/guided');
assert.equal(c.status, 200, c.text);
const body = { choice: 'new/guided', confirmation: c.json().confirmation };
refused(await s.resolve(body), code);
refused(await s.resolve(body), 'confirmation-required');
assert.equal(s.calls.length, 2);
}
});
test('ordinary confirmations stay bound to the issuing session, choice, decision and business', async () => {
const s = setup({ answer: () => ORDINARY, resolve: a => ({ id: a.id, op: 'resolved', choice: a.choice }) });
let c = (await s.confirm('new/guided')).json();
refused(await s.resolve({ choice: 'existing:project', confirmation: c.confirmation }), 'confirmation-required');
c = (await s.confirm('new/guided')).json();
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
c = (await s.confirm('new/guided')).json();
s.current = null;
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'sign-in-required');
s.current = s.session;
refused(await s.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
const other = setup({ answer: () => ({ ...ORDINARY, business: 'other' }) });
other.confirmations = s.confirmations;
c = (await s.confirm('new/guided')).json();
refused(await other.resolve({ choice: 'new/guided', confirmation: c.confirmation }), 'confirmation-required');
assert.equal(s.calls.filter(x => x.verb === 'decision.resolve.console').length, 0);
assert.equal(other.calls.length, 0);
});
test('the gates before the body: method, read-only, sign-in and same origin', async () => { test('the gates before the body: method, read-only, sign-in and same origin', async () => {
const s = setup(); const s = setup();
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed'); refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');