feat(console): show the PRD draft and revoke an approved snapshot (#1839)
This commit is contained in:
1 parent
b5a58b48b4
commit
68bef84fa7
10 files changed
+563
-54
No files matched your search
@@ -6058,3 +6058,58 @@ SSH fixture; only its owned test child and scratch daemon were terminated, the
|
||||
interrupted evidence was kept at `/mnt/storage/scratch/tmp/task5b-bus-interrupted.log`,
|
||||
and the isolated full rerun passed. All started runs were joined before completion.
|
||||
Local commit only, no push, no live instance or credential-file access.
|
||||
|
||||
## 2026-11-04 — #797 / #1839 Task 4 Console slice — before
|
||||
|
||||
On `t3/lifecycle-task-4-ui` from b5a58b48b. The lead authorized the missing
|
||||
owner-only draft snapshot counter after the initial UI investigation stopped:
|
||||
add `{id, revision}` from the project's snapshot events, without a migration or
|
||||
new verb. Build the exact PRD/requirements panel, existing decision confirmation
|
||||
and guarded owner revoke route. Keep bus edits in draftConsole; leave broker,
|
||||
runtime, roles and harness untouched. Verify targeted request/activation tests
|
||||
and whole webui/bus suites in fresh scratch TMPDIRs; commit locally, never push.
|
||||
|
||||
## 2026-11-04 — #797 / #1839 Task 4 Console slice — after
|
||||
|
||||
The owner request panel now shows the exact current product PRD version, full
|
||||
PRD and requirements, selected record paths/blob IDs, records/spec commits and
|
||||
mode revision. Guided approval reuses the existing one-use decision flow and
|
||||
names that candidate; changed or refused material drops its controls. The owner
|
||||
can explicitly revoke an approved snapshot with a bounded reason. The HTTP route
|
||||
binds project/snapshot to the URL's request and sends the snapshot-event counter,
|
||||
never request revision. Refusals stay refusals, uncertain receipts stay unknown,
|
||||
and neither is retried. Sign-out purges material and ignores late reads/writes.
|
||||
Mode-change receipts immediately clear invalidated material.
|
||||
|
||||
The lead-approved bus adjustment is confined to draftConsole in
|
||||
request-lifecycle.mjs: retain current owner authorization/freshness checks and
|
||||
add snapshot {id, revision} from the request's active snapshot and the project's
|
||||
latest snapshotEvents revision. No candidate returns null candidate/PRD and an
|
||||
empty requirement list, so the counter remains readable after revocation. No
|
||||
broker, runtime, requirement-catalog, migration, role or harness file changed.
|
||||
|
||||
Final gates (all zero failures, cancellations and skips): whole bus **1540/1540**
|
||||
(bus.log); whole webui **554/554** (webui-verified.log, test-concurrency=4);
|
||||
request-http + request-browser **42/42** (targeted-verified.log). Each run used
|
||||
its own mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX. Dependencies were
|
||||
linked without installation: root node_modules symlink; webui node_modules a
|
||||
real directory with four per-entry symlinks to the canonical checkout.
|
||||
|
||||
Evidence directory: /mnt/storage/scratch/tmp/6YKcQI/. Earlier green runs:
|
||||
activation + HTTP 42/42 (green-http-bus.log), browser 19/19 (browser-first.log),
|
||||
requests 41/41 (targeted-webui.log), first whole webui 553/553 (webui.log),
|
||||
requests 42/42 after invalidation (targeted-webui-final.log), and isolated
|
||||
send + tester-CLI 14/14 (refusal-timeout-check.log). Expected red runs:
|
||||
activation 21/22 (red-bus.log), HTTP 14/20 (red-http.log), requests 26/39
|
||||
(red-webui.log), invalidation 0/1 (red-invalidation.log), uncertain receipt
|
||||
0/1 (red-unknown.log). Every red run also had zero skips.
|
||||
|
||||
One intermediate whole-webui run (webui-final.log) had 552 passing out of 554,
|
||||
one failure and one cancellation: send.test.mjs's "without --send the route is
|
||||
read-only like every other write, and config says no send" got provider-unavailable
|
||||
503 at sign-in; tester-cli.test.mjs's private-Unix-socket CLI test timed out at
|
||||
30 seconds. Host load was above 52 on 16 CPUs. Both files passed 14/14 isolated;
|
||||
the full rerun at four concurrent files passed 554/554 without changing any wait
|
||||
or timeout. The same 15-second __stubReady browser waits remain. All started
|
||||
runs were joined. Queue remains with the lead after the linked-worktree refusal.
|
||||
Local commit only; no push or live instance/credential-file access.
|
||||
@@ -2008,3 +2008,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls | #797 / Gitea #1839 lifecycle Task 3 | Intake Console controls and guided answers: 3p->3q migration, PM-only request.question, owner-only request.answer/attach/mode/cancel.console verbs, request-http.mjs routes with owner, same-origin and bounded-body checks, requests.js panel under the owner's chat message (mode choice through the decision confirmation, guided answers, project attach, mode change, cancel, reload-safe lost answer). Bus 1492/1492, webui 538/538, intake+bootstrap 70/70, zero skips; local commit only
|
||||
2026-11-04T17:18Z | Claude Sonnet 5.5 implementation sub-agent, t3/console-request-intake-controls-r2 | #797 / Gitea #1839 lifecycle Task 3 rebase | Rebased 7fd0c3562 and c52218c65 onto c098fa022 (Task 4 core, #802). Migration renamed 3p-3q to 3q-3r, SCHEMA_VERSION 3r; Task 4's 3p-3q left byte-identical; the kind list is the union (Task 4's prd.submitted, prd.activated, prd.invalidated, mode.changed plus question.asked, question.answered, attach.selected). Task 3's own request.mode.console dropped for Task 4's modeConsole; webui maps request-mode-revision-conflict and reads Task 4's draft shape. Bus 1536/1536, webui 540/540, targeted bus 108/108 and webui 28/28, prd-activation 21/21, project prd 6/6, zero skips; not pushed.
|
||||
- 2026-11-04 — Codex implementation sub-agent — Lifecycle Task 5b (#797/#1839): shared scoped catalogue admission, explicit validated Stack binding, TaskScope/provenance and Git project guards; based on 103168614 plus cherry-picked Task 4 1627e24d7. Final gates 35/107/132/1501 (+32 configuration/tools), zero skips; logs /mnt/storage/scratch/tmp/task5b-{newtests,regression,tasks,bus,tools}.log. No migration, local commit only, never pushed; no live instance touched.
|
||||
|
||||
2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-4-ui | #797 / Gitea #1839 lifecycle Task 4 Console slice | Exact PRD/requirements panel, existing decision confirmation and owner revoke with route/session/origin/body binding; lead-approved owner draft snapshot-event counter in draftConsole only, no migration/roles/harness/broker/runtime edits. Final bus 1540/1540, webui 554/554 (concurrency 4), targeted Console 42/42, zero skips; evidence /mnt/storage/scratch/tmp/6YKcQI/ includes expected red tests, the interim SSO/CLI timing failures and isolated 14/14 recovery. Linked-worktree queue refusal left to lead; local commit only, no push or live/credential-file access.
|
||||
@@ -397,11 +397,13 @@ export function createRequestLifecycle({ journal, services }) {
|
||||
draftConsole: async (identity, args) => {
|
||||
exact(args, ['request']); if (!id(args.request)) refuse('invalid-request');
|
||||
const state = stateOf(identity.business, args.request); authorize(identity, state, 'draft');
|
||||
if (!state.candidate) refuse('prd-candidate-required');
|
||||
const material = await services.readCandidate(state.candidate, state.business);
|
||||
const material = state.candidate ? await services.readCandidate(state.candidate, state.business)
|
||||
: { candidate: null, prd: null, requirements: [] };
|
||||
const current = stateOf(state.business, state.request); authorize(identity, current, 'draft');
|
||||
if (current.revision !== state.revision) refuse('request-revision-conflict');
|
||||
return { candidate: material.candidate, prd: material.prd, requirements: material.requirements };
|
||||
const revision = current.project ? journal.snapshotEvents(current.business, current.project).at(-1)?.revision ?? 0 : 0;
|
||||
return { candidate: material.candidate, prd: material.prd, requirements: material.requirements,
|
||||
snapshot: { id: current.snapshot, revision } };
|
||||
}, resume, catalog: services.catalog,
|
||||
decisionClosed: (business, decision) => {
|
||||
if (closing) return Promise.resolve();
|
||||
|
||||
@@ -168,6 +168,29 @@ test('owner draft read shows exact bound PRD and requirements without a generic
|
||||
for (const who of ['pm','human','reader']) assert.throws(() => f.call(who, 'request.draft.console', { request: f.request }), { code: 'human-required' });
|
||||
});
|
||||
|
||||
test('owner draft snapshot counter follows activation and revoke independently of the request revision', async t => {
|
||||
const f = await setup(t);
|
||||
const read = () => f.call('console', 'request.draft.console', { request: f.request });
|
||||
assert.deepEqual(await read(), { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 0 } });
|
||||
const sub = await f.submit();
|
||||
assert.deepEqual((await read()).snapshot, { id: null, revision: 0 });
|
||||
f.approve(sub);
|
||||
const snap = await f.activate(sub), active = await read();
|
||||
assert.deepEqual(active.snapshot, { id: snap.id, revision: 1 });
|
||||
assert.notEqual(active.snapshot.revision, f.journal.readRequest('demo', f.request).revision);
|
||||
const args = { project: 'timer', snapshot: active.snapshot.id, revision: active.snapshot.revision, reason: 'Replace this version', op: 'revoke-read' };
|
||||
await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, revision: f.journal.readRequest('demo', f.request).revision }), { code: 'request-revision-conflict' });
|
||||
const receipt = await f.call('console', 'prd.revoke.console', args);
|
||||
assert.equal(receipt.kind, 'revoked');
|
||||
assert.deepEqual(await read(), { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 2 } });
|
||||
await assert.rejects(f.call('console', 'prd.revoke.console', { ...args, op: 'stale-read' }), { code: 'request-revision-conflict' });
|
||||
for (const who of ['pm', 'cto', 'coder', 'human', 'reader'])
|
||||
assert.throws(() => f.call(who, 'request.draft.console', { request: f.request }), { code: 'human-required' });
|
||||
// The next submission sees the existing project counter even though it has no active snapshot yet.
|
||||
await f.call('pm', 'prd.submit', { ...f.args, revision: f.journal.readRequest('demo', f.request).revision, op: 'prd-again' });
|
||||
assert.deepEqual((await read()).snapshot, { id: null, revision: 2 });
|
||||
});
|
||||
|
||||
test('3p to 3r migration preserves existing request rows and the append-only guards', async t => {
|
||||
const { readFileSync, chmodSync } = await import('node:fs');
|
||||
const { Store, schemaDigest } = await import('../src/store.mjs');
|
||||
|
||||
@@ -370,13 +370,15 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
ten-second beat while a thread is shown, and never writes by mounting. It needs
|
||||
`--sso`; its writes need `--send` too (otherwise the panel reads and says it is
|
||||
read-only; `/api/config` reports `requests` and `requestWrites` to the owner
|
||||
only). Four owner writes, each `POST` with a single same-origin `Origin`,
|
||||
only). Five owner writes, each `POST` with a single same-origin `Origin`,
|
||||
`Sec-Fetch-Site: same-origin`, `application/json`, a bounded body of exactly
|
||||
these fields and a live session, spawning `console-cli.mjs` with the signed-in
|
||||
subject (the page never names a business, subject or verb):
|
||||
`/answer {revision, question, op, text}` (64 KB body, 8000 characters of text),
|
||||
`/attach {revision, project, op}`, `/mode {revision, mode_revision, mode, op}`,
|
||||
`/cancel {revision, op}`. Each returns the new request state; a bus refusal
|
||||
`/cancel {revision, op}`, and Task 4's `/revoke {project, snapshot, revision, reason, op}`.
|
||||
The first four return the new request state; revoke returns a bound snapshot
|
||||
revocation receipt. A bus refusal
|
||||
maps to a named code and any unrecognised outcome is `outcome-unknown`, never
|
||||
retried. Choosing the mode (new autonomous, new guided, or an existing project)
|
||||
answers the selection decision through the same confirmation endpoints as the
|
||||
@@ -396,9 +398,32 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
reads the request first, and only the owner's click sends the stored answer
|
||||
again with the same operation id, so the bus records it once. Sign-out clears
|
||||
the panels and the stored answers. `GET /api/requests/<request>/draft` relays
|
||||
the bus's `request.draft.console` reply (`candidate`, `prd`, `requirements`) when
|
||||
its candidate names that request, `prd-candidate-required` (409) before a PRD is
|
||||
submitted, and `draft-unavailable` (503) on a bus whose verb list lacks the verb.
|
||||
the bus's `request.draft.console` reply (`candidate`, `prd`, `requirements`,
|
||||
`snapshot`). Task 4 displays the exact PRD version, full PRD and requirement
|
||||
text, selected paths/blob IDs, records commit, confirmed intent commit and mode
|
||||
revision. Text is inert, including control/bidi characters. A missing or
|
||||
mismatched draft offers no approval or revoke action. Guided PRD decisions
|
||||
use the existing confirmation/resolve endpoints; their confirmation names the
|
||||
displayed version and records commit. A changed candidate or request drops
|
||||
the confirmation. Autonomous decisions stay with their eligible arbiter.
|
||||
The panel shows the approved snapshot and offers **Revoke approved snapshot**
|
||||
to a writable owner. Revocation requires a reason (1–1024 characters) and an
|
||||
explicit confirmation; it keeps existing evidence and makes pending work wait
|
||||
for replacement authority. The route checks project and snapshot against the
|
||||
URL's current request before writing. Its revision is the draft read's
|
||||
`snapshot.revision`, the project's latest snapshot-event counter, never the
|
||||
request revision. The body limit is 4096 bytes. Stale revisions refuse 409
|
||||
`request-revision-conflict`; a foreign snapshot refuses 403
|
||||
`requirement-snapshot-refused`. A refusal is reported, and a lost or malformed
|
||||
receipt remains unknown; neither retries. The page reads again after a revoke.
|
||||
With no candidate (including after revocation), the draft read returns
|
||||
`candidate: null`, `prd: null`, `requirements: []` and `snapshot: {id: null,
|
||||
revision}`; revision is zero before the project's first snapshot event.
|
||||
Changed selected PRD/requirement content refuses 409 `prd-candidate-stale`,
|
||||
changed intent refuses 409 `stale-spec`, and a bus whose verb list lacks the
|
||||
draft read refuses 503 `draft-unavailable`. These routes remain owner-only;
|
||||
without SSO, request GETs refuse 503 and POSTs refuse 403. Sign-out clears the
|
||||
draft panel and ignores late replies.
|
||||
- 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.
|
||||
- A task's list row can only say its current state came from a Vikunja read,
|
||||
|
||||
@@ -62,3 +62,6 @@
|
||||
.request-panel .btn { margin-block: .15rem; }
|
||||
.request-panel [aria-pressed="true"] { outline: 2px solid var(--action); outline-offset: -2px; }
|
||||
.request-receipt:focus { outline: 2px solid var(--action); outline-offset: 2px; }
|
||||
.request-prd { margin-block: .75rem; border-block: 1px solid var(--border); padding-block: .5rem; }
|
||||
.request-prd pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--mono); }
|
||||
.request-requirement { margin-block: .75rem; }
|
||||
@@ -9,7 +9,7 @@
|
||||
- Nothing is sent twice by this page. A refused or lost write is reported and left for a deliberate
|
||||
click; a lost answer is stored (sessionStorage) before it is sent, and after a reload the page
|
||||
reads the request first and offers the same operation again, never sends it by itself.
|
||||
- The mode decision uses the existing confirmation endpoints (/api/decisions/:id/confirmation and
|
||||
- Mode and guided PRD decisions use the existing confirmation endpoints (/api/decisions/:id/confirmation and
|
||||
/resolve), exactly like the inbox answer panel. A text answer opens no decision.
|
||||
- After sign-out nothing is read, nothing is drawn and a late response changes nothing. */
|
||||
(() => {
|
||||
@@ -30,6 +30,21 @@
|
||||
const positive = n => Number.isSafeInteger(n) && n > 0;
|
||||
const text = v => typeof v === 'string';
|
||||
const isState = (v, id) => !!v && typeof v === 'object' && !Array.isArray(v) && v.version === 1 && v.request === id && positive(v.revision) && text(v.phase);
|
||||
const canonical = v => Array.isArray(v) ? v.map(canonical) : v && typeof v === 'object'
|
||||
? Object.fromEntries(Object.keys(v).sort().map(k => [k, canonical(v[k])])) : v;
|
||||
const same = (a, b) => JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
|
||||
const isMaterial = (v, s) => {
|
||||
if (!v || !Array.isArray(v.requirements) || !v.snapshot || !Number.isSafeInteger(v.snapshot.revision) || v.snapshot.revision < 0 ||
|
||||
v.snapshot.id !== s.snapshot || !(v.snapshot.id === null || text(v.snapshot.id) && ID.test(v.snapshot.id) && positive(v.snapshot.revision))) return false;
|
||||
if (!s.candidate) return v.candidate === null && v.prd === null && v.requirements.length === 0;
|
||||
const c = v.candidate;
|
||||
return same(c, s.candidate) && c.request === s.request && c.project === s.project && c.mode_revision === s.mode_revision &&
|
||||
text(c.prd_version) && text(c.records_commit) && text(c.prd_path) && text(c.prd_blob) && text(c.spec_commit) &&
|
||||
text(v.prd) && Array.isArray(c.requirements) && c.requirements.length > 0 && c.requirements.length <= 64 &&
|
||||
c.requirements.length === v.requirements.length && v.requirements.every((r, i) => r && text(r.id) && text(r.title) && text(r.text) &&
|
||||
c.requirements[i] && r.id === c.requirements[i].id && r.title === c.requirements[i].title && text(c.requirements[i].path) && text(c.requirements[i].blob));
|
||||
};
|
||||
const prdDecision = s => s.phase === 'prd-review' && s.wait?.kind === 'decision' && s.wait.code === 'prd-approval' && text(s.wait.subject);
|
||||
|
||||
const panels = new Map();
|
||||
let signedOut = false, timer = null, config = null, loading = null;
|
||||
@@ -82,8 +97,8 @@
|
||||
let p = panels.get(id);
|
||||
if (!p) {
|
||||
const stored = load(id);
|
||||
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, writes: false, at: 0, reading: null, stored, sig: '',
|
||||
ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, draft: stored?.text ?? '' } };
|
||||
p = { id, state: null, missing: false, failed: false, stale: false, inbox: null, projects: null, material: null, materialError: '', writes: false, at: 0, reading: null, stored, sig: '',
|
||||
ui: { pending: null, receipt: '', confirm: null, cancelAsk: false, revokeAsk: false, reason: '', draft: stored?.text ?? '' } };
|
||||
panels.set(id, p);
|
||||
}
|
||||
return p;
|
||||
@@ -106,6 +121,11 @@
|
||||
if (!p.ui.receipt) p.ui.receipt = q && q.id === rec.question && q.answer !== null ? 'The question was answered. Your earlier answer is not sent again.' : 'That question is closed. Your unsent answer was discarded.';
|
||||
}
|
||||
function adopt(p, s) {
|
||||
if (p.state && (s.revision !== p.state.revision || !same(s.candidate, p.state.candidate))) p.ui.confirm = null;
|
||||
if (p.state && s.snapshot !== p.state.snapshot) p.ui.revokeAsk = false;
|
||||
if (p.state && (s.snapshot !== p.state.snapshot || !same(s.candidate, p.state.candidate))) {
|
||||
p.material = null; p.materialError = '';
|
||||
}
|
||||
if (!p.state || s.revision >= p.state.revision) p.state = s;
|
||||
p.missing = false; p.failed = false; p.stale = false;
|
||||
reconcile(p);
|
||||
@@ -126,14 +146,37 @@
|
||||
return `<p class="request-wait">${line}</p>`;
|
||||
}
|
||||
|
||||
function prdHtml(p, busy, can) {
|
||||
if (p.materialError) return `<p class="request-prd-error" role="status">${txt(p.materialError)} Approval and revocation are unavailable. Use Read again.</p>`;
|
||||
const m = p.material;
|
||||
if (!m) return '';
|
||||
if (!m.candidate) return '<p class="request-prd-empty">No current PRD candidate or approved snapshot. Waiting for a PRD submission.</p>';
|
||||
const c = m.candidate;
|
||||
let h = `<section class="request-prd" aria-label="Exact product PRD"><p><b>Product PRD ${txt(c.prd_version)}</b></p>
|
||||
<p class="small">Records commit: <code>${txt(c.records_commit)}</code><br>PRD: <code>${txt(c.prd_path)}</code><br>PRD blob: <code>${txt(c.prd_blob)}</code><br>Confirmed intent commit: <code>${txt(c.spec_commit)}</code><br>Mode revision: ${txt(c.mode_revision)}</p>
|
||||
<pre class="request-prd-text">${txt(m.prd)}</pre><div class="request-requirements" aria-label="Requirements">${m.requirements.map((r, i) =>
|
||||
`<section class="request-requirement"><p><b>${txt(r.id)} — ${txt(r.title)}</b></p><p class="small"><code>${txt(c.requirements[i].path)}</code><br>Blob: <code>${txt(c.requirements[i].blob)}</code></p><pre>${txt(r.text)}</pre></section>`).join('')}</div>`;
|
||||
if (m.snapshot.id) {
|
||||
h += `<p class="request-snapshot">Approved snapshot: <code>${txt(m.snapshot.id)}</code></p>`;
|
||||
if (can) h += `<div class="request-revoke">${p.ui.revokeAsk
|
||||
? `<form class="request-revoke-form" novalidate><p>Revoke this approved snapshot? Pending work will wait for replacement authority. Existing evidence is kept.</p>
|
||||
<label for="chat-request-revoke-${esc(p.id)}">Reason for revocation</label><textarea id="chat-request-revoke-${esc(p.id)}" rows="3"${busy}>${esc(p.ui.reason)}</textarea>
|
||||
<button type="submit" class="btn" data-request-revoke-confirm${busy}>${p.ui.pending === 'revoke' ? 'Revoking…' : 'Confirm revocation'}</button>
|
||||
<button type="button" class="btn" data-request-revoke-back${busy}>Keep approval</button></form>`
|
||||
: `<button type="button" class="btn" data-request-revoke${busy}>Revoke approved snapshot</button>`}</div>`;
|
||||
} else h += '<p class="request-snapshot">This candidate has no active approved snapshot.</p>';
|
||||
return h + '</section>';
|
||||
}
|
||||
|
||||
function html(p) {
|
||||
const s = p.state, ui = p.ui;
|
||||
if (!s) return p.failed ? `<section class="request-panel" data-request="${esc(p.id)}"><p class="request-unavailable">This request could not be read. <button type="button" class="btn" data-request-reload>Read again</button></p></section>` : '';
|
||||
const busy = ui.pending ? ' disabled' : '', can = p.writes;
|
||||
const busy = ui.pending || p.reading ? ' disabled' : '', can = p.writes && !p.stale;
|
||||
const q = s.question && text(s.question.id) && text(s.question.text) ? s.question : null;
|
||||
let body = `<p class="request-head"><b>Request</b> <span class="request-phase">Phase: ${txt(s.phase)}</span>${s.mode ? ` <span class="request-mode">Mode: ${txt(s.mode)}</span>` : ''}</p>`;
|
||||
if (s.project) body += `<p class="request-project">Project: <code>${txt(s.project)}</code>${s.phase === 'cancelled' ? ' (kept)' : ''}</p>`;
|
||||
body += waitHtml(s);
|
||||
body += prdHtml(p, busy, can);
|
||||
if (q) {
|
||||
body += q.answer === null
|
||||
? `<div class="request-question"><p>The project manager asks:</p><p class="request-question-text">${txt(q.text)}</p></div>`
|
||||
@@ -143,11 +186,12 @@
|
||||
if (can && ui.confirm) {
|
||||
const c = ui.confirm;
|
||||
body += `<div class="request-confirm" role="group" aria-label="Confirm your choice"><p>${txt(c.question)}</p><p>Your choice: <b>${txt(c.text)}</b></p>
|
||||
${c.candidate ? `<p>Exact product PRD <b>${txt(c.candidate.prd_version)}</b> at <code>${txt(c.candidate.records_commit)}</code>. The PRD and requirements are shown above.</p>` : ''}
|
||||
<p class="small muted">Confirm to record this choice. This confirmation works once and lasts two minutes.</p>
|
||||
<button type="button" class="btn primary" data-request-confirm${busy}>${ui.pending === 'confirm' ? 'Answering…' : 'Confirm'}</button>
|
||||
<button type="button" class="btn" data-request-unconfirm${busy}>Back</button></div>`;
|
||||
} else if (can && s.phase === 'selection' && p.inbox) {
|
||||
body += `<div class="request-choices" role="group" aria-label="Choose how to run this request"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
|
||||
} else if (can && (s.phase === 'selection' || prdDecision(s) && p.material?.candidate) && p.inbox) {
|
||||
body += `<div class="request-choices" role="group" aria-label="${prdDecision(s) ? 'Review this exact product PRD version' : 'Choose how to run this request'}"><p class="small muted">Each button opens a confirmation first. Nothing is answered until you confirm.</p>${p.inbox.console.options.map(o =>
|
||||
`<button type="button" class="btn" data-request-choice="${esc(o.key)}"${busy}>${txt(o.text)}</button>`).join(' ')}</div>`;
|
||||
}
|
||||
if (can && pickerOpen(s)) {
|
||||
@@ -185,32 +229,45 @@
|
||||
// Redraw every mount of this request, keeping the keyboard where it was.
|
||||
function draw(p) {
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
p.sig = signature(p);
|
||||
for (const el of document.querySelectorAll('.request-mount[data-request]')) {
|
||||
if (el.dataset.request !== p.id) continue;
|
||||
const a = document.activeElement, inside = !!a && a !== document.body && el.contains(a);
|
||||
const area = inside && a.matches('textarea') ? { start: a.selectionStart, end: a.selectionEnd } : null;
|
||||
const area = inside && a.matches('textarea') ? { id: a.id, start: a.selectionStart, end: a.selectionEnd } : null;
|
||||
const mark = inside && !area ? [...a.attributes].find(x => x.name.startsWith('data-request-')) : null;
|
||||
el.innerHTML = html(p);
|
||||
if (!inside) continue;
|
||||
let focus = null;
|
||||
if (area) {
|
||||
focus = el.querySelector('textarea');
|
||||
focus = el.querySelector(`#${CSS.escape(area.id)}`);
|
||||
if (focus) focus.setSelectionRange(area.start, area.end);
|
||||
} else if (mark) focus = el.querySelector(`[${mark.name}="${CSS.escape(mark.value)}"]`);
|
||||
(focus && !focus.disabled ? focus : el.querySelector('.request-receipt'))?.focus({ preventScroll: true });
|
||||
}
|
||||
}
|
||||
const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.failed, p.stale, p.missing, p.writes]);
|
||||
const signature = p => JSON.stringify([p.state, p.inbox, p.projects, p.material, p.materialError, p.failed, p.stale, p.missing, p.writes, Boolean(p.reading)]);
|
||||
|
||||
// The reads that follow a state: the mode decision the selection waits on and the project picker.
|
||||
async function side(p) {
|
||||
const s = p.state;
|
||||
p.inbox = null;
|
||||
p.inbox = null; p.material = null; p.materialError = '';
|
||||
if (s.project) {
|
||||
const { res, json } = await call(`/api/requests/${enc(p.id)}/draft`);
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
if (res?.ok && isMaterial(json, s)) p.material = json;
|
||||
else {
|
||||
p.ui.confirm = null; p.ui.revokeAsk = false;
|
||||
p.materialError = res && !res.ok && text(json?.error)
|
||||
? `Draft refused: ${text(json.message) ? json.message : json.error} (${json.error}).`
|
||||
: 'The exact draft could not be verified against this request.';
|
||||
}
|
||||
}
|
||||
if (!p.writes) { p.projects = null; return; }
|
||||
if (s.phase === 'selection' && text(s.selection?.decision)) {
|
||||
const decision = s.phase === 'selection' ? s.selection?.decision : prdDecision(s) && p.material?.candidate ? s.wait.subject : null;
|
||||
if (text(decision)) {
|
||||
const { res, json } = await call('/api/bus/inbox');
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
const row = res?.ok && Array.isArray(json?.rows) ? json.rows.find(r => r && r.id === s.selection.decision) : null;
|
||||
const row = res?.ok && Array.isArray(json?.rows) ? json.rows.find(r => r && r.id === decision) : null;
|
||||
if (row && row.console?.kind === 'decision' && options(row.console.options).length === row.console.options.length && row.console.options.length) p.inbox = row;
|
||||
}
|
||||
if (pickerOpen(s)) {
|
||||
@@ -229,12 +286,13 @@
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
if (res?.ok && isState(json, p.id)) { adopt(p, json); await side(p); }
|
||||
else if (res?.status === 404) { p.state = null; p.missing = true; p.failed = false; }
|
||||
else if (p.state) p.stale = true;
|
||||
else if (p.state) { p.stale = true; p.material = null; p.inbox = null; p.ui.confirm = null; p.ui.revokeAsk = false; }
|
||||
else p.failed = true;
|
||||
p.at = Date.now();
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
const sig = signature(p);
|
||||
if (sig !== p.sig) { p.sig = sig; draw(p); }
|
||||
// The read is complete before drawing, so a successful refresh re-enables controls.
|
||||
p.reading = null;
|
||||
if (signature(p) !== p.sig) draw(p);
|
||||
} finally { p.reading = null; }
|
||||
})();
|
||||
return p.reading;
|
||||
@@ -246,17 +304,20 @@
|
||||
const { res, json } = await post(`/api/requests/${enc(p.id)}/${action}`, body);
|
||||
if (out() || panels.get(p.id) !== p) return null;
|
||||
p.ui.pending = null;
|
||||
if (res?.ok && isState(json, p.id)) return { ok: true, state: json };
|
||||
if (res && res.status >= 400 && res.status < 500 && res.status !== 408 && text(json?.error)) return { refused: true, code: json.error, message: text(json.message) ? json.message : json.error };
|
||||
if (res?.ok && (action === 'revoke'
|
||||
? json?.kind === 'revoked' && json.snapshot === body.snapshot && json.revision === body.revision + 1 && text(json.cause)
|
||||
: isState(json, p.id))) return { ok: true, state: action === 'revoke' ? null : json };
|
||||
if (res && !res.ok && res.status !== 408 && text(json?.error) && json.error !== 'outcome-unknown')
|
||||
return { refused: true, code: json.error, message: text(json.message) ? json.message : json.error };
|
||||
return { unknown: true };
|
||||
}
|
||||
// The receipt and the follow-up read for one write's result. A refused or lost write is read
|
||||
// against, never repeated.
|
||||
function settle(p, r, okText, noText) {
|
||||
if (!r) return false;
|
||||
if (r.ok) { adopt(p, r.state); p.ui.receipt = okText; }
|
||||
if (r.ok) { if (r.state) adopt(p, r.state); p.ui.receipt = okText; }
|
||||
else if (r.refused) p.ui.receipt = `${noText}: ${r.message} (${r.code}).`;
|
||||
else p.ui.receipt = 'Outcome unknown: the change may have reached the project manager. Nothing was sent again; the request is being read.';
|
||||
else p.ui.receipt = 'Outcome unknown: the change may have reached the bus. Nothing was sent again; the request is being read.';
|
||||
p.sig = signature(p); draw(p);
|
||||
if (!r.ok) void read(p);
|
||||
return r.ok === true;
|
||||
@@ -305,7 +366,19 @@
|
||||
settle(p, await send(p, 'cancel', { revision: s.revision, op: opId('cancel') }), 'Request cancelled. Any project already created is kept.', 'Not cancelled');
|
||||
}
|
||||
|
||||
// The mode decision. The first click only asks the Console for a one-use confirmation that names
|
||||
async function revoke(p) {
|
||||
const s = p.state, m = p.material, ui = p.ui;
|
||||
if (!p.writes || p.stale || p.reading || ui.pending || !ui.revokeAsk || !s || !m?.snapshot.id || !isMaterial(m, s)) return;
|
||||
if (!ui.reason.trim() || ui.reason.length > 1024 || ui.reason.includes('\0')) { ui.receipt = 'Write a reason of 1–1024 characters before revoking.'; draw(p); return; }
|
||||
ui.receipt = '';
|
||||
const r = await send(p, 'revoke', { project: s.project, snapshot: m.snapshot.id, revision: m.snapshot.revision, reason: ui.reason, op: opId('revoke') });
|
||||
if (!r) return;
|
||||
if (r.ok) { ui.revokeAsk = false; ui.reason = ''; p.material = null; }
|
||||
settle(p, r, 'Approved snapshot revoked. Pending work waits for replacement authority; existing evidence is kept.', 'Not revoked');
|
||||
if (r.ok) void read(p);
|
||||
}
|
||||
|
||||
// The mode or guided PRD decision. The first click only asks the Console for a one-use confirmation that names
|
||||
// the answer; the second records it. A refusal drops the confirmation and an unknown outcome is
|
||||
// reported, never repeated.
|
||||
const DECIDE_FALLBACK = {
|
||||
@@ -317,13 +390,16 @@
|
||||
const notAnswered = r => `Not answered here: ${r.message} (${r.code}).${r.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}`;
|
||||
async function choose(p, key) {
|
||||
const row = p.inbox, ui = p.ui;
|
||||
if (!p.writes || ui.pending || !row || !row.console.options.some(o => o.key === key)) return;
|
||||
if (!p.writes || p.stale || p.reading || ui.pending || !row || !row.console.options.some(o => o.key === key)) return;
|
||||
const revision = p.state.revision, candidate = prdDecision(p.state) ? p.material?.candidate : null;
|
||||
if (prdDecision(p.state) && !candidate) return;
|
||||
ui.pending = 'choice'; ui.receipt = ''; ui.confirm = null; draw(p);
|
||||
const { res, json } = await post(`/api/decisions/${enc(row.id)}/confirmation`, { choice: key });
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
ui.pending = null;
|
||||
if (res?.ok && json?.id === row.id && json.action === 'answer' && json.kind === 'decision' && !json.grant && json.choice === key && text(json.text) && text(json.confirmation)) {
|
||||
ui.confirm = { id: row.id, choice: key, text: json.text, question: text(json.question) ? json.question : '', confirmation: json.confirmation };
|
||||
if (p.state.revision === revision && same(candidate, prdDecision(p.state) ? p.material?.candidate : null) &&
|
||||
res?.ok && json?.id === row.id && json.action === 'answer' && json.kind === 'decision' && !json.grant && json.choice === key && text(json.text) && text(json.confirmation)) {
|
||||
ui.confirm = { id: row.id, choice: key, text: json.text, question: text(json.question) ? json.question : '', confirmation: json.confirmation, candidate, revision };
|
||||
draw(p);
|
||||
} else {
|
||||
ui.receipt = notAnswered(refusal(res, json, 'confirmation-unavailable')); draw(p);
|
||||
@@ -332,7 +408,8 @@
|
||||
}
|
||||
async function confirm(p) {
|
||||
const c = p.ui.confirm, ui = p.ui;
|
||||
if (!p.writes || ui.pending || !c) return;
|
||||
if (!p.writes || p.stale || p.reading || ui.pending || !c || c.revision !== p.state?.revision ||
|
||||
c.candidate && (!same(c.candidate, p.material?.candidate) || p.state.wait?.subject !== c.id)) return;
|
||||
ui.pending = 'confirm'; ui.receipt = ''; draw(p);
|
||||
const { res, json } = await post(`/api/decisions/${enc(c.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
|
||||
if (out() || panels.get(p.id) !== p) return;
|
||||
@@ -349,7 +426,7 @@
|
||||
const el = button?.closest('.request-panel');
|
||||
if (!button || !el || out()) return;
|
||||
const p = panels.get(el.dataset.request);
|
||||
if (!p || button.disabled) return;
|
||||
if (!p || button.disabled || p.ui.pending || p.reading) return;
|
||||
const d = button.dataset;
|
||||
if ('requestReload' in d) { p.ui.receipt = ''; void read(p); draw(p); }
|
||||
else if ('requestChoice' in d) void choose(p, d.requestChoice);
|
||||
@@ -361,21 +438,26 @@
|
||||
if (o) { p.ui.draft = o.text; draw(p); el.querySelector('textarea')?.focus({ preventScroll: true }); }
|
||||
} else if ('requestResend' in d) void answer(p, true);
|
||||
else if ('requestMode' in d) void mode(p, d.requestMode);
|
||||
else if ('requestRevoke' in d) { p.ui.revokeAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('.request-revoke textarea')?.focus({ preventScroll: true }); }
|
||||
else if ('requestRevokeBack' in d) { p.ui.revokeAsk = false; draw(p); }
|
||||
else if ('requestCancel' in d) { p.ui.cancelAsk = true; p.ui.receipt = ''; draw(p); el.querySelector('[data-request-cancel-keep]')?.focus({ preventScroll: true }); }
|
||||
else if ('requestCancelKeep' in d) { p.ui.cancelAsk = false; draw(p); }
|
||||
else if ('requestCancelConfirm' in d) void cancel(p);
|
||||
});
|
||||
document.addEventListener('submit', event => {
|
||||
const form = event.target.closest?.('form.request-answer');
|
||||
const form = event.target.closest?.('form.request-answer, form.request-revoke-form');
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
const p = panels.get(form.closest('.request-panel')?.dataset.request);
|
||||
if (p && !out()) void answer(p);
|
||||
if (p && !out()) { if (form.matches('.request-revoke-form')) void revoke(p); else void answer(p); }
|
||||
});
|
||||
document.addEventListener('input', event => {
|
||||
const area = event.target.closest?.('.request-answer textarea');
|
||||
const p = area && panels.get(area.closest('.request-panel')?.dataset.request);
|
||||
if (p && !out()) p.ui.draft = area.value;
|
||||
const reason = event.target.closest?.('.request-revoke textarea');
|
||||
const owner = reason && panels.get(reason.closest('.request-panel')?.dataset.request);
|
||||
if (owner && !out()) owner.ui.reason = reason.value;
|
||||
});
|
||||
|
||||
// The Console does not push request changes, so a shown panel re-reads on a slow beat while the page
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
// #797 / #1839 Task 3: the Console's request intake routes. Two reads and four owner writes over one
|
||||
// #797 / #1839 Tasks 3/4: the Console's request intake routes. Reads and owner writes over one
|
||||
// request, each through the console wrapper (consoleCall) as the signed-in (provider, iss, sub):
|
||||
// GET /api/requests/:request request.get.console -> RequestState
|
||||
// GET /api/requests/:request/projects request.projects.console -> [{ project, name }]
|
||||
// GET /api/requests/:request/draft request.draft.console -> { candidate, prd, requirements } (Task 4's verb)
|
||||
// GET /api/requests/:request/draft request.draft.console -> { candidate, prd, requirements, snapshot }
|
||||
// POST /api/requests/:request/answer request.answer.console { revision, question, op, text }
|
||||
// POST /api/requests/:request/attach request.attach.console { revision, project, op }
|
||||
// POST /api/requests/:request/mode request.mode.console { revision, mode_revision, mode, op }
|
||||
// POST /api/requests/:request/cancel request.cancel.console { revision, op }
|
||||
// POST /api/requests/:request/revoke prd.revoke.console { project, snapshot, revision, reason, op }
|
||||
// Revoke uses the project's snapshot-event revision, and returns a revocation receipt rather than
|
||||
// request state. Its project and snapshot are checked against the route's current request first.
|
||||
// Only the signed-in owner reaches this module (serve.mjs fences granted users and the tester before
|
||||
// it). A write needs the Console's own Origin and same-origin fetch metadata, a bounded JSON body of
|
||||
// exactly the fields above, and a live session. The browser never names the business, the subject or
|
||||
// a verb: the verb comes from the route, the subject from the session. Nothing here retries, and a
|
||||
// reply that is not the state of this request is never relayed. A text answer is an answer to the
|
||||
// a verb: the verb comes from the route, the subject from the session. Nothing here retries. A state
|
||||
// reply must name this request; a revoke receipt must name this snapshot and its next revision. A text answer is an answer to the
|
||||
// PM's question, never a PRD approval; the decision answer routes are decision-http.mjs's.
|
||||
import { BusReadError } from './bus.mjs';
|
||||
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
|
||||
|
||||
export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel))?$/;
|
||||
export const REQUEST_ROUTE = /^\/api\/requests\/([^/]+)(?:\/(projects|draft|answer|attach|mode|cancel|revoke))?$/;
|
||||
const WRITE_VERBS = Object.freeze({
|
||||
answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console',
|
||||
answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console', revoke: 'prd.revoke.console',
|
||||
});
|
||||
const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'request.projects.console', draft: 'request.draft.console' });
|
||||
// The exact key set of each write body. A key outside it, such as `request`, `business` or `subject`,
|
||||
@@ -26,6 +29,7 @@ const READ_VERBS = Object.freeze({ get: 'request.get.console', projects: 'reques
|
||||
const FIELDS = Object.freeze({
|
||||
answer: ['revision', 'question', 'op', 'text'], attach: ['revision', 'project', 'op'],
|
||||
mode: ['revision', 'mode_revision', 'mode', 'op'], cancel: ['revision', 'op'],
|
||||
revoke: ['project', 'snapshot', 'revision', 'reason', 'op'],
|
||||
});
|
||||
// The endpoint contract. Each refusal is one of these, never a 200.
|
||||
export const REQUEST_REFUSALS = Object.freeze({
|
||||
@@ -47,6 +51,11 @@ export const REQUEST_REFUSALS = Object.freeze({
|
||||
'request-project-refused': [403, 'That project cannot be picked for this request.'],
|
||||
'request-operation-conflict': [409, 'That operation id was already used for a different change.'],
|
||||
'prd-candidate-required': [409, 'No PRD draft has been submitted for this request yet.'],
|
||||
'prd-candidate-stale': [409, 'The PRD or a requirement changed. A new submission is needed.'],
|
||||
'stale-spec': [409, 'The confirmed intent changed. A new PRD submission is needed.'],
|
||||
'requirement-snapshot-refused': [403, 'That approved snapshot does not belong to this request.'],
|
||||
'requirement-snapshot-revoked': [409, 'That approved snapshot has been revoked. Read the request again.'],
|
||||
'processing-halted': [409, 'Processing is stopped. This operation was refused.'],
|
||||
'draft-unavailable': [503, 'The draft PRD view is not available yet.'],
|
||||
unavailable: [503, 'The request is unavailable. Try again later.'],
|
||||
'outcome-unknown': [503, 'The change may have reached the bus. Read the request before trying again.'],
|
||||
@@ -64,6 +73,9 @@ const BUS_CODES = {
|
||||
'request-question-mismatch': 'request-question-mismatch', 'request-project-refused': 'request-project-refused',
|
||||
'request-operation-conflict': 'request-operation-conflict', 'invalid-request': 'invalid-request',
|
||||
'prd-candidate-required': 'prd-candidate-required',
|
||||
'prd-candidate-stale': 'prd-candidate-stale', 'stale-spec': 'stale-spec',
|
||||
'requirement-snapshot-refused': 'requirement-snapshot-refused', 'requirement-snapshot-revoked': 'requirement-snapshot-revoked',
|
||||
'processing-halted': 'processing-halted', 'read-only': 'read-only',
|
||||
};
|
||||
const BODY_MAX = 4096;
|
||||
const ANSWER_BODY_MAX = 65536;
|
||||
@@ -108,13 +120,23 @@ function fields(action, value) {
|
||||
}
|
||||
if (action === 'attach' && (typeof value.project !== 'string' || !ID.test(value.project))) return null;
|
||||
if (action === 'mode' && (!positive(value.mode_revision) || !MODES.includes(value.mode))) return null;
|
||||
if (action === 'revoke' && (typeof value.project !== 'string' || !ID.test(value.project) || typeof value.snapshot !== 'string' || !ID.test(value.snapshot) ||
|
||||
typeof value.reason !== 'string' || !value.reason.trim() || value.reason.length > 1024 || value.reason.includes('\0'))) return null;
|
||||
return Object.fromEntries(FIELDS[action].map(k => [k, value[k]]));
|
||||
}
|
||||
// A reply is relayed only when it is the state of this request: version 1, this id, a positive
|
||||
// revision and a phase.
|
||||
const state = (v, id) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && v.version === 1 && v.request === id && positive(v.revision) && typeof v.phase === 'string';
|
||||
// The draft is relayed only when its candidate names this request and it carries the PRD text and its requirements.
|
||||
const draft = (v, id) => Boolean(v) && typeof v === 'object' && !Array.isArray(v) && v.candidate?.request === id && typeof v.prd === 'string' && Array.isArray(v.requirements);
|
||||
// Empty material still carries the counter after invalidation. Never infer it from request revision.
|
||||
const draft = (v, id) => {
|
||||
if (!v || typeof v !== 'object' || !Array.isArray(v.requirements) || !v.snapshot || !Number.isSafeInteger(v.snapshot.revision) || v.snapshot.revision < 0 ||
|
||||
!(v.snapshot.id === null || typeof v.snapshot.id === 'string' && ID.test(v.snapshot.id) && positive(v.snapshot.revision))) return false;
|
||||
if (v.candidate === null) return v.prd === null && v.requirements.length === 0;
|
||||
return v.candidate?.request === id && typeof v.prd === 'string' && Array.isArray(v.candidate.requirements) &&
|
||||
v.candidate.requirements.length === v.requirements.length && v.requirements.every((r, i) => r && v.candidate.requirements[i] && r.id === v.candidate.requirements[i].id &&
|
||||
r.title === v.candidate.requirements[i].title && typeof r.text === 'string');
|
||||
};
|
||||
const revoked = (v, args) => v && v.kind === 'revoked' && v.snapshot === args.snapshot && v.revision === args.revision + 1 && typeof v.cause === 'string';
|
||||
const picker = v => Array.isArray(v) && v.every(p => p && typeof p === 'object' && typeof p.project === 'string' && typeof p.name === 'string');
|
||||
|
||||
// `session` is the live sign-in session object this request passed, and `live()` reads it again right
|
||||
@@ -164,9 +186,19 @@ export async function requestHTTP(req, res, { path, requestRead, requestWrite, s
|
||||
if (!args) return refuse('invalid-request');
|
||||
const tail = ` revision=${args.revision}`;
|
||||
if (live() !== session) return refuse('sign-in-required', tail);
|
||||
if (action === 'revoke') {
|
||||
if (!requestRead) return refuse('unavailable', tail);
|
||||
let binding;
|
||||
try { binding = await requestRead(READ_VERBS.get, { request: id }, { subject }); }
|
||||
catch (err) { return refuse(busCode(err, 'unavailable'), tail); }
|
||||
if (!state(binding, id)) return refuse('unavailable', tail);
|
||||
if (binding.project !== args.project || binding.snapshot !== args.snapshot) return refuse('requirement-snapshot-refused', tail);
|
||||
if (live() !== session) return refuse('sign-in-required', tail);
|
||||
}
|
||||
let reply;
|
||||
try { reply = await requestWrite(WRITE_VERBS[action], { request: id, ...args }, { subject }); } catch (err) { return refuse(busCode(err, 'outcome-unknown'), tail); }
|
||||
if (!state(reply, id)) return refuse('outcome-unknown', tail);
|
||||
try { reply = await requestWrite(WRITE_VERBS[action], action === 'revoke' ? args : { request: id, ...args }, { subject }); }
|
||||
catch (err) { return refuse(busCode(err, 'outcome-unknown'), tail); }
|
||||
if (action === 'revoke' ? !revoked(reply, args) : !state(reply, id)) return refuse('outcome-unknown', tail);
|
||||
line(`${tail} outcome=ok`);
|
||||
return json(res, 200, reply);
|
||||
}
|
||||
@@ -22,6 +22,17 @@ const PICKING = state({ route: 'existing', wait: wait('setup', 'request-existing
|
||||
const QUESTION = { id: 'q-1', text: 'Which platform?', options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'Command line' }], answer: null };
|
||||
const ASKING = state({ revision: 7, phase: 'discovery', project: 'factory', chat_project: 'c1', mode: 'guided', mode_revision: 1, question: QUESTION,
|
||||
wait: wait('question', 'question', 'q-1', { resume_phase: 'discovery', observed_revision: 7 }) });
|
||||
const CANDIDATE = { version: 1, request: R, project: 'factory', prd_version: '1.2', records_commit: 'a'.repeat(40), prd_path: 'docs/prd-1.2.md',
|
||||
prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1,
|
||||
requirements: [{ id: 'REQ-TIMER-1', title: 'Timer', path: 'requirements/REQ-TIMER-1.json', blob: 'd'.repeat(40) }] };
|
||||
const DRAFT = { candidate: CANDIDATE, prd: '# Timer 1.2\n' + HOSTILE,
|
||||
requirements: [{ recordVersion: 1, id: 'REQ-TIMER-1', title: 'Timer', text: 'Counts down\n' + HOSTILE, status: 'draft' }], snapshot: { id: null, revision: 2 } };
|
||||
const REVIEWING = state({ ...ASKING, revision: 11, phase: 'prd-review', question: null, candidate: CANDIDATE,
|
||||
wait: wait('decision', 'prd-approval', D, { resume_phase: 'prd-review', observed_revision: 11 }) });
|
||||
const APPROVED = { ...REVIEWING, revision: 14, phase: 'planning', snapshot: 'prd-approved', wait: wait('setup', 'request-planning-unavailable', R) };
|
||||
const ACTIVE_DRAFT = { ...DRAFT, snapshot: { id: 'prd-approved', revision: 3 } };
|
||||
const PRD_INBOX = [{ id: D, action: 'prd.approve', question: 'Approve product PRD 1.2 at ' + CANDIDATE.records_commit + '?',
|
||||
console: { kind: 'decision', digest: 'e'.repeat(64), options: [{ key: 'yes', text: 'Approve this exact product PRD version' }, { key: 'no', text: 'Request changes' }] } }];
|
||||
const ROW = { id: M, body: 'Build me a countdown timer', from_role: 'human', at: '2026-11-03T00:00:00Z', request: R };
|
||||
const INBOX = [{ id: D, question: 'Confirm the resource plan.', options: [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }],
|
||||
console: { kind: 'decision', digest: 'd'.repeat(64), options: [{ key: 'autonomous', text: 'New project — autonomous' }, { key: 'guided', text: 'New project — guided' }, { key: 'existing', text: 'Add to existing project' }] } }];
|
||||
@@ -30,6 +41,7 @@ const stubFor = boot => `(() => {
|
||||
const boot = JSON.parse(sessionStorage.getItem('__boot') || '{}');
|
||||
const real = fetch.bind(window);
|
||||
window.__rreads = []; window.__rwrites = []; window.__rAnswers = [];
|
||||
window.__drafts = boot.drafts || {}; window.__draftStatus = {};
|
||||
window.__states = boot.states || {}; window.__projects = boot.projects || []; window.__inbox = boot.inbox || []; window.__pages = boot.pages || [];
|
||||
window.__readStatus = {};
|
||||
window.__stubReady = true;
|
||||
@@ -51,9 +63,13 @@ const stubFor = boot => `(() => {
|
||||
return json(a.body ?? a.set, a.status || 200);
|
||||
}
|
||||
window.__rreads.push(path);
|
||||
const m = /^\\/api\\/requests\\/([^/]+?)(?:\\/(projects))?$/.exec(path);
|
||||
const m = /^\\/api\\/requests\\/([^/]+?)(?:\\/(projects|draft))?$/.exec(path);
|
||||
if (!m) return json({ error: 'request-not-found' }, 404);
|
||||
if (window.__readStatus[m[1]]) return json({ error: 'sign-in-required', message: 'Sign in.' }, window.__readStatus[m[1]]);
|
||||
if (m[2] === 'draft') {
|
||||
if (window.__draftHold) await new Promise(r => window.__releaseDraft = r);
|
||||
return window.__draftStatus[m[1]] ? json(window.__draftStatus[m[1]].body, window.__draftStatus[m[1]].status) : json(window.__drafts[m[1]]);
|
||||
}
|
||||
if (m[2] === 'projects') return window.__projectsFail ? json({ error: 'unavailable', message: 'x' }, 503) : json(window.__projects);
|
||||
const s = window.__states[m[1]];
|
||||
return s ? json(s) : json({ error: 'request-not-found', message: 'No such request.' }, 404);
|
||||
@@ -101,7 +117,7 @@ async function setup(t, { boot = {}, suffix = '' } = {}) {
|
||||
}
|
||||
await w('document.querySelector(".chat-conversation")');
|
||||
};
|
||||
await w('document.querySelector(".chat-conversation")');
|
||||
await w('window.__stubReady === true && document.readyState === "complete" && document.querySelector(".chat-conversation")');
|
||||
return { b, w, set, click, text, count, writes, answer, reload, sleep };
|
||||
}
|
||||
const P = `.request-panel[data-request="${R}"]`;
|
||||
@@ -383,3 +399,160 @@ test('request panel: sign-out clears the panel and a late write response changes
|
||||
await out.b.evaluate(`window.__readStatus['${R}']=401;document.querySelector('${P} [data-request-reload]').click();true`);
|
||||
await out.w('document.body.classList.contains("signed-out")');
|
||||
});
|
||||
|
||||
|
||||
test('PRD panel: exact candidate, full requirements and version are inert, visible before approval and read-only without writes', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } });
|
||||
await e.w(`document.querySelector('${P} .request-prd-text')`);
|
||||
assert.equal(await e.text(`${P} .request-prd-text`), '# Timer 1.2\n' + HOSTILE.replace('\u001b', '\\u001b').replace('\u202e', '\\u202e'));
|
||||
assert.match(await e.text(`${P} .request-prd`), /1\.2/);
|
||||
for (const value of [CANDIDATE.records_commit, CANDIDATE.prd_path, CANDIDATE.prd_blob, CANDIDATE.spec_commit, 'REQ-TIMER-1', CANDIDATE.requirements[0].blob])
|
||||
assert.ok((await e.text(`${P} .request-prd`)).includes(value), value);
|
||||
assert.ok((await e.text(`${P} .request-requirements`)).includes('Counts down'));
|
||||
assert.equal(await e.count(`${P} img, ${P} script`), 0);
|
||||
assert.equal(await e.b.evaluate('window.injected'), undefined);
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
assert.equal(await e.count(`${P} [data-request-choice]`), 2);
|
||||
const ro = await setup(t, { suffix: '?readonly', boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT }, inbox: PRD_INBOX } });
|
||||
await ro.w(`document.querySelector('${P} .request-prd-text')`);
|
||||
assert.equal(await ro.count(`${P} [data-request-choice], ${P} [data-request-revoke]`), 0);
|
||||
assert.match(await ro.text(`${P} .request-snapshot`), /prd-approved/);
|
||||
assert.deepEqual(await ro.writes(), []);
|
||||
});
|
||||
|
||||
test('PRD panel: guided approval names the displayed exact version through the existing one-use confirmation', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } });
|
||||
await e.w(`document.querySelector('${P} [data-request-choice="yes"]')`);
|
||||
await e.answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'yes', text: 'Approve this exact product PRD version', question: PRD_INBOX[0].question, confirmation: 'prd-token' } });
|
||||
await e.click(`${P} [data-request-choice="yes"]`);
|
||||
await e.w(`document.querySelector('${P} [data-request-confirm]')`);
|
||||
assert.match(await e.text(`${P} .request-confirm`), /1\.2/);
|
||||
assert.ok((await e.text(`${P} .request-confirm`)).includes(CANDIDATE.records_commit));
|
||||
assert.equal((await e.writes()).length, 1, 'preview never resolves');
|
||||
await e.b.evaluate(`window.__states['${R}']=${JSON.stringify(APPROVED)};window.__drafts['${R}']=${JSON.stringify(ACTIVE_DRAFT)};true`);
|
||||
await e.answer({ body: { id: D, op: 'resolved', choice: 'yes' } });
|
||||
await e.click(`${P} [data-request-confirm]`);
|
||||
await e.w(`document.querySelector('${P} .request-snapshot')?.textContent.includes('prd-approved')`);
|
||||
assert.deepEqual((await e.writes()).map(x => [x.path, x.body]), [
|
||||
[`/api/decisions/${D}/confirmation`, { choice: 'yes' }], [`/api/decisions/${D}/resolve`, { choice: 'yes', confirmation: 'prd-token' }],
|
||||
]);
|
||||
assert.match(await e.text(`${P} .request-receipt`), /Recorded/);
|
||||
});
|
||||
|
||||
test('PRD panel: autonomous approval stays with its arbiter and an unavailable, stale or mismatched draft offers no approval', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: { ...REVIEWING, mode: 'autonomous' } }, drafts: { [R]: DRAFT }, inbox: [] } });
|
||||
await e.w(`document.querySelector('${P} .request-prd-text')`);
|
||||
assert.equal(await e.count(`${P} [data-request-choice]`), 0);
|
||||
for (const [status, error] of [[403, 'human-required'], [409, 'prd-candidate-stale'], [409, 'request-revision-conflict'], [503, 'unavailable']]) {
|
||||
await e.b.evaluate(`window.__draftStatus['${R}']={status:${status},body:{error:'${error}',message:'Draft refused.'}};true`);
|
||||
await e.click(`${P} [data-request-reload]`);
|
||||
await e.w(`document.querySelector('${P} .request-prd-error')?.textContent.includes('${error}')`);
|
||||
assert.equal(await e.count(`${P} .request-prd-text, ${P} [data-request-choice], ${P} [data-request-revoke]`), 0);
|
||||
}
|
||||
await e.b.evaluate(`delete window.__draftStatus['${R}'];window.__drafts['${R}']=${JSON.stringify({ ...DRAFT, candidate: { ...CANDIDATE, records_commit: 'f'.repeat(40) } })};window.__inbox=${JSON.stringify(PRD_INBOX)};true`);
|
||||
await e.click(`${P} [data-request-reload]`);
|
||||
await e.w(`document.querySelector('${P} .request-prd-error')`);
|
||||
assert.equal(await e.count(`${P} [data-request-choice]`), 0, 'a different candidate cannot be approved as the request candidate');
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
});
|
||||
|
||||
test('PRD panel: refreshing a changed candidate drops an open confirmation', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } });
|
||||
await e.w(`document.querySelector('${P} [data-request-choice="yes"]')`);
|
||||
await e.answer({ body: { id: D, action: 'answer', kind: 'decision', choice: 'yes', text: 'Approve', question: PRD_INBOX[0].question, confirmation: 'stale-token' } });
|
||||
await e.click(`${P} [data-request-choice="yes"]`);
|
||||
await e.w(`document.querySelector('${P} [data-request-confirm]')`);
|
||||
await e.b.evaluate(`window.__states['${R}']=${JSON.stringify({ ...ASKING, revision: 12 })};true`);
|
||||
await e.click(`${P} [data-request-reload]`);
|
||||
await e.w(`!document.querySelector('${P} [data-request-confirm]')`);
|
||||
assert.equal((await e.writes()).length, 1);
|
||||
});
|
||||
|
||||
test('PRD revoke: asks for a reason and confirmation, sends the snapshot counter and reads the resulting empty draft', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } });
|
||||
await e.w(`document.querySelector('${P} [data-request-revoke]')`);
|
||||
await e.click(`${P} [data-request-revoke]`);
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
await e.click(`${P} [data-request-revoke-confirm]`);
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
assert.match(await e.text(`${P} .request-receipt`), /reason/i);
|
||||
await e.set(`${P} .request-revoke textarea`, 'x'.repeat(1025));
|
||||
await e.click(`${P} [data-request-revoke-confirm]`);
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
await e.set(`${P} .request-revoke textarea`, 'Replace the requirements');
|
||||
await e.b.evaluate(`window.__drafts['${R}']=${JSON.stringify({ candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 4 } })};true`);
|
||||
await e.answer({ set: { ...ASKING, revision: 15, question: null, candidate: null, snapshot: null }, body: { snapshot: 'prd-approved', revision: 4, kind: 'revoked', cause: 'host' } });
|
||||
await e.click(`${P} [data-request-revoke-confirm]`);
|
||||
await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('revoked') && !document.querySelector('${P} [data-request-revoke]')`);
|
||||
const [sent] = await e.writes();
|
||||
assert.equal(sent.path, `/api/requests/${R}/revoke`);
|
||||
assert.deepEqual({ ...sent.body, op: undefined }, { project: 'factory', snapshot: 'prd-approved', revision: 3, reason: 'Replace the requirements', op: undefined });
|
||||
assert.notEqual(sent.body.revision, APPROVED.revision);
|
||||
assert.match(sent.body.op, /^revoke-[0-9a-f]{24}$/);
|
||||
assert.equal(sent.redirect, 'manual');
|
||||
await e.w(`document.querySelector('${P} .request-prd-empty')`);
|
||||
});
|
||||
|
||||
test('PRD revoke: stale, refused, malformed and lost outcomes never show success or retry', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } });
|
||||
await e.w(`document.querySelector('${P} [data-request-revoke]')`);
|
||||
for (const [reply, receipt] of [
|
||||
[{ status: 409, body: { error: 'request-revision-conflict', message: 'Snapshot changed.' } }, 'request-revision-conflict'],
|
||||
[{ status: 403, body: { error: 'requirement-snapshot-refused', message: 'Snapshot refused.' } }, 'requirement-snapshot-refused'],
|
||||
[{ status: 503, body: { error: 'unavailable', message: 'Revoke refused.' } }, 'unavailable'],
|
||||
[{ status: 503, body: { error: 'outcome-unknown', message: 'The revoke may have reached the bus.' } }, 'unknown'],
|
||||
[{ body: { snapshot: 'wrong', revision: 4, kind: 'revoked' } }, 'unknown'], [{ reject: true }, 'unknown'],
|
||||
]) {
|
||||
if (await e.count(`${P} [data-request-revoke]`)) await e.click(`${P} [data-request-revoke]`);
|
||||
await e.set(`${P} .request-revoke textarea`, 'Replace it');
|
||||
await e.answer(reply);
|
||||
const n = (await e.writes()).length;
|
||||
await e.click(`${P} [data-request-revoke-confirm]`);
|
||||
await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('${receipt}')`);
|
||||
assert.doesNotMatch(await e.text(`${P} .request-receipt`), /^Approved snapshot revoked/);
|
||||
assert.match(await e.text(`${P} .request-receipt`), receipt === 'unknown' ? /^Outcome unknown/ : /^Not revoked/);
|
||||
await e.w(`!document.querySelector('${P} [data-request-reload]')?.disabled`);
|
||||
assert.equal((await e.writes()).length, n + 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('PRD revoke: a stale refusal rereads the project counter before the next deliberate click', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } });
|
||||
await e.w(`document.querySelector('${P} [data-request-revoke]')`);
|
||||
await e.click(`${P} [data-request-revoke]`);
|
||||
await e.set(`${P} .request-revoke textarea`, 'Replace it');
|
||||
await e.b.evaluate(`window.__drafts['${R}'].snapshot.revision=5;true`);
|
||||
await e.answer({ status: 409, body: { error: 'request-revision-conflict', message: 'Read the snapshot again.' } });
|
||||
await e.click(`${P} [data-request-revoke-confirm]`);
|
||||
await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('request-revision-conflict') && !document.querySelector('${P} [data-request-reload]')?.disabled`);
|
||||
assert.equal((await e.writes()).length, 1);
|
||||
await e.answer({ status: 403, body: { error: 'requirement-snapshot-refused', message: 'Refused.' } });
|
||||
await e.click(`${P} [data-request-revoke-confirm]`);
|
||||
await e.w(`document.querySelector('${P} .request-receipt')?.textContent.includes('requirement-snapshot-refused')`);
|
||||
const writes = await e.writes();
|
||||
assert.deepEqual(writes.map(x => x.body.revision), [3, 5]);
|
||||
assert.notEqual(writes[1].body.op, writes[0].body.op);
|
||||
});
|
||||
|
||||
test('PRD panel: sign-out clears candidate material and ignores a late draft read', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: REVIEWING }, drafts: { [R]: DRAFT }, inbox: PRD_INBOX } });
|
||||
await e.w(`document.querySelector('${P} .request-prd-text')`);
|
||||
await e.b.evaluate('window.__draftHold=true;true');
|
||||
await e.click(`${P} [data-request-reload]`);
|
||||
await e.w('typeof window.__releaseDraft === "function"');
|
||||
await e.b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseDraft();true');
|
||||
await e.w('document.body.classList.contains("signed-out")');
|
||||
assert.equal(await e.count('.request-prd-text, [data-request-revoke], [data-request-choice]'), 0);
|
||||
assert.doesNotMatch(await e.b.evaluate('document.body.innerText'), /Counts down|Timer 1\.2/);
|
||||
assert.deepEqual(await e.writes(), []);
|
||||
});
|
||||
|
||||
test('PRD panel: a successful mode change immediately clears the invalidated draft and revoke control', async t => {
|
||||
const e = await setup(t, { boot: { states: { [R]: APPROVED }, drafts: { [R]: ACTIVE_DRAFT } } });
|
||||
await e.w(`document.querySelector('${P} [data-request-revoke]')`);
|
||||
await e.answer({ set: { ...ASKING, revision: 15, question: null, mode: 'autonomous', mode_revision: 2 } });
|
||||
await e.click(`${P} [data-request-mode="autonomous"]`);
|
||||
await e.w(`document.querySelector('${P} .request-mode')?.textContent.includes('autonomous')`);
|
||||
assert.equal(await e.count(`${P} .request-prd-text, ${P} .request-snapshot, ${P} [data-request-revoke]`), 0);
|
||||
assert.equal((await e.writes()).length, 1);
|
||||
});
|
||||
@@ -30,7 +30,14 @@ const BODIES = {
|
||||
attach: { revision: 5, project: 'old', op: 'att-1' },
|
||||
mode: { revision: 5, mode_revision: 1, mode: 'guided', op: 'mode-1' },
|
||||
cancel: { revision: 5, op: 'cancel-1' },
|
||||
revoke: { project: 'old', snapshot: 'prd-active', revision: 3, reason: 'Replace the draft', op: 'revoke-1' },
|
||||
};
|
||||
const CANDIDATE = { version: 1, request: 'r1', project: 'old', prd_version: '1.0', records_commit: 'a'.repeat(40), prd_path: 'docs/prd-1.0.md',
|
||||
prd_blob: 'b'.repeat(40), spec_commit: 'c'.repeat(40), mode_revision: 1,
|
||||
requirements: [{ id: 'R1', title: 'Timer', path: 'requirements/R1.json', blob: 'd'.repeat(40) }] };
|
||||
const DRAFT = { candidate: CANDIDATE, prd: '# PRD\n', requirements: [{ recordVersion: 1, id: 'R1', title: 'Timer', text: 'Counts down', status: 'draft' }], snapshot: { id: 'prd-active', revision: 3 } };
|
||||
const ACTIVE = { ...STATE, candidate: CANDIDATE, snapshot: 'prd-active', phase: 'planning' };
|
||||
const REVOKED = { snapshot: 'prd-active', revision: 4, kind: 'revoked', cause: 'opaque host receipt' };
|
||||
const VERBS = { answer: 'request.answer.console', attach: 'request.attach.console', mode: 'request.mode.console', cancel: 'request.cancel.console' };
|
||||
|
||||
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
|
||||
@@ -71,8 +78,8 @@ const refused = (res, code) => {
|
||||
assert.equal(res.headers['cache-control'], 'no-store');
|
||||
};
|
||||
|
||||
test('the route pattern takes a request id and one of five suffixes, and nothing else', () => {
|
||||
for (const ok of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft', '/api/requests/r1/answer', '/api/requests/r1/attach', '/api/requests/r1/mode', '/api/requests/r1/cancel'])
|
||||
test('the route pattern takes a request id and a known suffix, and nothing else', () => {
|
||||
for (const ok of ['/api/requests/r1', '/api/requests/r1/projects', '/api/requests/r1/draft', '/api/requests/r1/answer', '/api/requests/r1/attach', '/api/requests/r1/mode', '/api/requests/r1/cancel', '/api/requests/r1/revoke'])
|
||||
assert.match(ok, REQUEST_ROUTE);
|
||||
for (const bad of ['/api/requests', '/api/requests/', '/api/requests/r1/other', '/api/requests/r1/answer/x', '/api/requests/a/b/answer']) assert.doesNotMatch(bad, REQUEST_ROUTE);
|
||||
});
|
||||
@@ -113,7 +120,7 @@ test('the draft read is Task 4\'s verb: it fails closed on a bus without it and
|
||||
const s = setup({ verbs: CONSOLE_VERBS.filter(v => v !== 'request.draft.console') });
|
||||
refused(await s.get('/api/requests/r1/draft'), 'draft-unavailable');
|
||||
assert.equal(s.reads.length, 0, 'no bus call and no invented PRD content');
|
||||
const record = { candidate: { request: 'r1', project: 'old', prd_version: '1.0' }, prd: '# PRD\n', requirements: [{ id: 'R1', text: 'Counts down' }] };
|
||||
const record = DRAFT;
|
||||
const t = setup({ read: async () => record });
|
||||
const ok = await t.get('/api/requests/r1/draft');
|
||||
assert.equal(ok.status, 200, ok.text);
|
||||
@@ -290,7 +297,7 @@ test('without a write transport the listener reads but every intake write is rea
|
||||
const config = await env.get(cookie, '/api/config');
|
||||
assert.equal(config.body.requests, true); assert.equal(config.body.requestWrites, false);
|
||||
assert.equal((await env.get(cookie, '/api/requests/r1')).status, 200);
|
||||
for (const action of ['answer', 'attach', 'mode', 'cancel']) {
|
||||
for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) {
|
||||
const r = await env.post(cookie, `/api/requests/r1/${action}`, BODIES[action]);
|
||||
assert.deepEqual([r.status, r.body.error], [403, 'read-only'], action);
|
||||
}
|
||||
@@ -323,7 +330,7 @@ test('a signed-out caller, a denied origin and a granted user all stop before th
|
||||
const alice = await env.signIn('alice-sub');
|
||||
const config = await env.get(alice, '/api/config');
|
||||
assert.equal(config.body.user, true); assert.equal(config.body.requests ?? false, false); assert.equal(config.body.requestWrites ?? false, false);
|
||||
for (const action of ['answer', 'attach', 'mode', 'cancel']) {
|
||||
for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) {
|
||||
const r = await env.post(alice, `/api/requests/r1/${action}`, BODIES[action]);
|
||||
assert.deepEqual([r.status, r.body.error], [403, 'owner-only'], action);
|
||||
}
|
||||
@@ -342,7 +349,7 @@ const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => {
|
||||
const r = await call(port, { path, host, headers: extra });
|
||||
seen.push([path, r.status, r.body.error]);
|
||||
}
|
||||
for (const action of ['answer', 'attach', 'mode', 'cancel']) {
|
||||
for (const action of ['answer', 'attach', 'mode', 'cancel', 'revoke']) {
|
||||
const r = await call(port, { method: 'POST', path: `/api/requests/r1/${action}`, host,
|
||||
headers: { ...extra, 'content-type': 'application/json' }, body: JSON.stringify(BODIES[action]) });
|
||||
seen.push([action, r.status, r.body.error]);
|
||||
@@ -353,7 +360,7 @@ const REFUSED_WITHOUT_OWNER = async (port, host, extra = {}) => {
|
||||
return seen;
|
||||
};
|
||||
const EXPECTED_WITHOUT_OWNER = [['/api/requests/r1', 503, 'unavailable'], ['/api/requests/r1/projects', 503, 'unavailable'], ['/api/requests/r1/draft', 503, 'unavailable'],
|
||||
['answer', 403, 'read-only'], ['attach', 403, 'read-only'], ['mode', 403, 'read-only'], ['cancel', 403, 'read-only'], ['wrong-method', 405, 405]];
|
||||
['answer', 403, 'read-only'], ['attach', 403, 'read-only'], ['mode', 403, 'read-only'], ['cancel', 403, 'read-only'], ['revoke', 403, 'read-only'], ['wrong-method', 405, 405]];
|
||||
|
||||
test('without --sso a request URL is refused by the listener and the process keeps serving', async t => {
|
||||
const crashes = [];
|
||||
@@ -407,3 +414,108 @@ test('the request verbs are the console wrapper\'s, and the Console names no own
|
||||
const http = readFileSync(new URL('../src/request-http.mjs', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(http, /['"]request\.(get|projects|answer|attach|mode|cancel|propose|open|question)['"]/, 'the HTTP seam names only .console verbs');
|
||||
});
|
||||
|
||||
|
||||
test('revoke binds the route to the live request project and snapshot, sends only catalogue fields, and validates its receipt', async () => {
|
||||
const s = setup({ read: async () => ACTIVE, write: async () => REVOKED });
|
||||
const res = await s.post('revoke');
|
||||
assert.equal(res.status, 200, res.text);
|
||||
assert.deepEqual(res.json(), REVOKED);
|
||||
assert.deepEqual(s.reads, [{ verb: 'request.get.console', args: { request: 'r1' }, ctx: { subject: SUBJECT } }]);
|
||||
assert.deepEqual(s.writes, [{ verb: 'prd.revoke.console', args: BODIES.revoke, ctx: { subject: SUBJECT } }]);
|
||||
assert.doesNotMatch(s.lines.join('\n'), /Replace the draft|opaque host receipt/);
|
||||
for (const body of [
|
||||
{ ...BODIES.revoke, project: 'other' }, { ...BODIES.revoke, snapshot: 'prd-other' },
|
||||
]) {
|
||||
const bound = setup({ read: async () => ACTIVE });
|
||||
refused(await bound.post('revoke', body), 'requirement-snapshot-refused');
|
||||
assert.equal(bound.writes.length, 0);
|
||||
}
|
||||
for (const reply of [null, { ...ACTIVE, request: 'r2' }, { ...ACTIVE, snapshot: null }]) {
|
||||
const bound = setup({ read: async () => reply });
|
||||
const out = await bound.post('revoke');
|
||||
assert.ok(out.status >= 400 && out.status !== 200, out.text);
|
||||
assert.equal(bound.writes.length, 0);
|
||||
}
|
||||
for (const reply of [null, ACTIVE, { ...REVOKED, snapshot: 'other' }, { ...REVOKED, kind: 'activated' }, { ...REVOKED, revision: 3 }]) {
|
||||
const bad = setup({ read: async () => ACTIVE, write: async () => reply });
|
||||
refused(await bad.post('revoke'), 'outcome-unknown');
|
||||
assert.equal(bad.writes.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('revoke enforces body, origin, transport and current-session gates before any write', async () => {
|
||||
const path = '/api/requests/r1/revoke', s = setup({ read: async () => ACTIVE });
|
||||
for (const body of [
|
||||
{ ...BODIES.revoke, request: 'r2' }, { ...BODIES.revoke, business: 'demo' }, { ...BODIES.revoke, subject: SUBJECT },
|
||||
{ ...BODIES.revoke, revision: 0 }, { ...BODIES.revoke, project: '../x' }, { ...BODIES.revoke, snapshot: '' },
|
||||
{ ...BODIES.revoke, reason: '' }, { ...BODIES.revoke, reason: ' ' }, { ...BODIES.revoke, reason: 'x'.repeat(1025) }, { ...BODIES.revoke, reason: 'a\0b' },
|
||||
{ ...BODIES.revoke, op: '' },
|
||||
]) refused(await s.post('revoke', body), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
|
||||
refused(await s.run(path, request({ body: BODIES.revoke, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
|
||||
refused(await s.run(path, request({ raw: 'x'.repeat(4097) })), 'request-too-large');
|
||||
refused(await s.run(path, request({ method: 'GET' })), 'method-not-allowed');
|
||||
refused(await s.run(path, request({ body: BODIES.revoke }), { requestWrite: null }), 'read-only');
|
||||
refused(await s.run(path, request({ body: BODIES.revoke }), { requestRead: null }), 'unavailable');
|
||||
refused(await s.run(path, request({ body: BODIES.revoke }), { session: null }), 'sign-in-required');
|
||||
for (const headers of [{ origin: null }, { origin: ['https://evil.test'] }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['cross-site'] }, { 'sec-fetch-site': null }])
|
||||
refused(await s.run(path, request({ body: BODIES.revoke, headers })), 'cross-origin');
|
||||
s.current = null;
|
||||
refused(await s.post('revoke'), 'sign-in-required');
|
||||
assert.equal(s.writes.length, 0);
|
||||
// Sign-out while the route's binding read is in flight must still stop the write.
|
||||
const late = setup({ read: async () => { late.current = null; return ACTIVE; } });
|
||||
refused(await late.post('revoke'), 'sign-in-required');
|
||||
assert.equal(late.writes.length, 0);
|
||||
});
|
||||
|
||||
test('draft panel data relays empty snapshots and refuses malformed counters and stale reads', async () => {
|
||||
const empty = { candidate: null, prd: null, requirements: [], snapshot: { id: null, revision: 2 } };
|
||||
const s = setup({ read: async () => empty });
|
||||
assert.deepEqual((await s.get('/api/requests/r1/draft')).json(), empty);
|
||||
for (const data of [{ ...DRAFT, snapshot: null }, { ...DRAFT, snapshot: { id: 'prd-active', revision: -1 } },
|
||||
{ ...DRAFT, snapshot: { id: 'prd-active', revision: 0 } }, { ...DRAFT, snapshot: { id: null, revision: '3' } },
|
||||
{ ...DRAFT, requirements: [{ ...DRAFT.requirements[0], id: 'other' }] }]) {
|
||||
const bad = setup({ read: async () => data });
|
||||
refused(await bad.get('/api/requests/r1/draft'), 'unavailable');
|
||||
}
|
||||
for (const code of ['prd-candidate-stale', 'stale-spec', 'request-revision-conflict']) {
|
||||
const bad = setup({ read: async () => { throw new BusReadError(code); } });
|
||||
const res = await bad.get('/api/requests/r1/draft');
|
||||
assert.equal(res.status, 409, res.text); assert.equal(res.json().error, code);
|
||||
}
|
||||
});
|
||||
|
||||
test('revoke refusals including stale revisions never become success or retry', async () => {
|
||||
for (const [code, status] of [['request-revision-conflict', 409], ['request-operation-conflict', 409], ['requirement-snapshot-refused', 403],
|
||||
['requirement-snapshot-revoked', 409], ['human-required', 403], ['processing-halted', 409], ['read-only', 403]]) {
|
||||
const s = setup({ read: async () => ACTIVE, write: async () => { throw new BusReadError(code); } });
|
||||
const res = await s.post('revoke');
|
||||
assert.equal(res.status, status, res.text); assert.equal(res.json().error, code);
|
||||
assert.equal(s.writes.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('the signed-in owner draft and revoke listener use the snapshot revision and preserve stale refusals', async t => {
|
||||
const writes = [];
|
||||
const env = await listener(t, {
|
||||
requestRead: async verb => verb === 'request.draft.console' ? DRAFT : ACTIVE,
|
||||
requestWrite: async (verb, args, ctx) => {
|
||||
writes.push({ verb, args, ctx });
|
||||
if (args.revision !== 3) throw new BusReadError('request-revision-conflict');
|
||||
return REVOKED;
|
||||
},
|
||||
});
|
||||
const cookie = await env.signIn();
|
||||
const draft = await env.get(cookie, '/api/requests/r1/draft');
|
||||
assert.equal(draft.status, 200); assert.deepEqual(draft.body, DRAFT);
|
||||
const stale = await env.post(cookie, '/api/requests/r1/revoke', { ...BODIES.revoke, revision: 2 });
|
||||
assert.deepEqual([stale.status, stale.body.error], [409, 'request-revision-conflict']);
|
||||
const ok = await env.post(cookie, '/api/requests/r1/revoke', { ...BODIES.revoke, revision: draft.body.snapshot.revision });
|
||||
assert.equal(ok.status, 200); assert.deepEqual(ok.body, REVOKED);
|
||||
assert.equal(writes.length, 2, 'no implicit retry');
|
||||
assert.equal(writes[1].verb, 'prd.revoke.console');
|
||||
assert.deepEqual(writes[1].args, BODIES.revoke);
|
||||
assert.equal(writes[1].ctx.subject.sub, 'user-a');
|
||||
});
|
||||
Reference in new issue
Block a user