Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8533c0c8 |
No files matched your search
@@ -0,0 +1,628 @@
|
||||
# Row 198 r5: assertion diff
|
||||
|
||||
Status: uncommitted working-tree review file. Nothing here has been committed or pushed, and no test has been run (CLEAR pending).
|
||||
|
||||
- Base: `78b69e1e6` (refactor). Rebase paused on pick 1 (`4288b7b16`). Commit 1 is staged; later edits are in the working tree.
|
||||
- Groups: A = tests that existed on refactor before row 198 (Mos reads these, mos-1272). B = tests row 198 adds (Sage rules).
|
||||
- Hunks: 24 group A, 3 group B. Changed assert lines: 230 removed, 61 added (hunk sums; the file-level diff includes non-assert lines).
|
||||
- Retired test names: 36 (list below).
|
||||
|
||||
Labels are from line-range attribution. A removed block can span several old tests, so the enclosing-test label is the first match only; the reason and the diff text are authoritative.
|
||||
|
||||
## Group A (existed on refactor before row 198)
|
||||
|
||||
### A1: control-board/tests/discord.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
|
||||
|
||||
Reason: Port: the HTTP /api/board fetch becomes board.index(); the 409 is the status board.reply returns in-process. Kept: state, connector.alive, registered null, forged task not used.
|
||||
|
||||
```diff
|
||||
- assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
|
||||
- assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
|
||||
- assert.equal(response.status, 409);
|
||||
+ assert.equal(payload.sessions[0].state, 'idle'); assert.equal(payload.sessions[0].connector.alive, true);
|
||||
+ assert.equal(payload.sessions[0].registered, null); assert.notEqual(payload.sessions[0].task, 'forged');
|
||||
+ assert.equal(status, 409);
|
||||
```
|
||||
|
||||
### A2: control-board/tests/discord.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): server rescans connector discovery and refuses HTTP reply without transport
|
||||
|
||||
Reason: Port: the HTTP /api/board fetch becomes board.index(); zero-sessions assert unchanged.
|
||||
|
||||
```diff
|
||||
- assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
|
||||
+ assert.equal((await board.index()).sessions.length, 0);
|
||||
```
|
||||
|
||||
### A4: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): isLoopbackHost: rejects non-loopback hosts
|
||||
|
||||
Reason: Retired writer HTTP asserts: page content-type and cache-control, healthz body, 404/405 and the 500 envelope. The writer opens no listener now. index.json and per-agent write asserts are not re-asserted here (gap).
|
||||
|
||||
```diff
|
||||
- assert.ok(caught instanceof ConfigError, "expected a ConfigError");
|
||||
- assert.equal(res.status, 200, path);
|
||||
- assert.equal(res.headers.get("content-type"), "text/html; charset=utf-8", path);
|
||||
- assert.equal(res.headers.get("cache-control"), "no-store", path);
|
||||
- assert.equal(await res.text(), page, path);
|
||||
- assert.equal(health.status, 200);
|
||||
- assert.deepEqual(await health.json(), { ok: true });
|
||||
- assert.equal(first.status, 200);
|
||||
- assert.equal(first.headers.get("content-type"), "application/json");
|
||||
- assert.equal(first.headers.get("cache-control"), "no-store");
|
||||
- assert.equal(firstBody.sessions[0].state, "idle");
|
||||
- assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
|
||||
- assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
|
||||
- assert.equal(second.status, 200);
|
||||
- assert.equal(secondBody.sessions[0].state, "working");
|
||||
- assert.equal(missing.status, 404);
|
||||
- assert.equal(posted.status, 405);
|
||||
- assert.equal(res.status, 200);
|
||||
- assert.equal(body.sessions[0].taskSource, "registration");
|
||||
- assert.equal(body.sessions[0].task, "registered task");
|
||||
- assert.deepEqual(body.registered, ["proj/agent1"]);
|
||||
- assert.deepEqual(body.registrationErrors, []);
|
||||
- assert.equal(res.status, 500);
|
||||
- assert.equal(res.headers.get("content-type"), "application/json");
|
||||
- assert.equal(typeof body.error, "string");
|
||||
- assert.ok(body.error.length > 0);
|
||||
```
|
||||
|
||||
### A5: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): CLI: serve refuses a non-loopback host with exit 2 and a refused: message
|
||||
|
||||
Reason: Retired with serve: CLI refusal now asserted by cli.mjs:14 via board.test.mjs (group B hunk 0).
|
||||
|
||||
```diff
|
||||
- assert.equal(r.status, 2);
|
||||
- assert.match(r.stderr, /^refused:/);
|
||||
```
|
||||
|
||||
### A6: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): CLI: live serve prints its URL and answers /healthz
|
||||
|
||||
Reason: Retired page.html source checks (esc, seen POST, healthz) and HTTP seen round-trip. page.html is deleted; markSeen order is in board.test.mjs. The esc() asserts have no in-process target (open gap).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 200);
|
||||
- assert.deepEqual(await res.json(), { ok: true });
|
||||
- assert.ok(m, "esc() must exist in page.html");
|
||||
- assert.equal(esc('<script>alert("x")</script>&\''), "<script>alert("x")</script>&'");
|
||||
- assert.equal(esc(null), "");
|
||||
- assert.equal(esc(undefined), "");
|
||||
- assert.equal(esc(42), "42");
|
||||
- assert.deepEqual(rawJoins, [], `API values concatenated into HTML without esc(): ${rawJoins.join(" | ")}`);
|
||||
- assert.ok(escCalls >= 15, `expected many esc() calls, saw ${escCalls}`);
|
||||
- assert.equal(postRes.status, 200);
|
||||
- assert.ok(postBody.seen.includes("proj/agent1"));
|
||||
- assert.ok(!postBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.equal(boardRes.status, 200);
|
||||
- assert.ok(boardBody.seen.includes("proj/agent1"));
|
||||
- assert.ok(!boardBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.equal(clearRes.status, 200);
|
||||
- assert.ok(!clearBody.seen.includes("proj/agent1"));
|
||||
- assert.ok(clearBody.waitingOnYou.includes("proj/agent1"));
|
||||
- assert.equal(res.status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written on a rejected content-type");
|
||||
- assert.equal(res.status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")));
|
||||
- if (status !== null) assert.equal(status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")), "seen.json must not be written for an oversized body");
|
||||
- assert.equal(res.status, 400);
|
||||
- assert.ok(!existsSync(join(boardDir, "seen.json")));
|
||||
- assert.equal(board.status, 405);
|
||||
- assert.equal(put.status, 405);
|
||||
```
|
||||
|
||||
### A7: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): CLI: scan --print marks a seen row with 's' and the summary line ends with 'N seen)'
|
||||
|
||||
Reason: Retired page.html source checks only (seenControl, Seen section, Hide seen, sourceTag escapes). No verified Console equivalent in this pass (open gap).
|
||||
|
||||
```diff
|
||||
- assert.ok(m, "seenControl() must exist in page.html");
|
||||
- assert.match(body, /esc\(rec\.project\)/);
|
||||
- assert.match(body, /esc\(rec\.agent\)/);
|
||||
- assert.match(body, /esc\(rec\.lastActivity\)/);
|
||||
- assert.match(html, /headers:\s*\{\s*"content-type":\s*"application\/json"\s*\}/, "the seen POST must send content-type: application/json");
|
||||
- assert.match(html, /<details id="seenDetails">(?![^>]*\sopen)/, "the Seen section must start collapsed");
|
||||
- assert.match(html, /<h2 id="seen-h">Seen<\/h2>/);
|
||||
- assert.ok(m, "renderSeen() must exist in page.html");
|
||||
- assert.match(m[0], /filter\(function \(r\) \{ return r\.seen; \}\)/, "the Seen section lists exactly the rows the API marks seen");
|
||||
- assert.match(m[0], /buildRowPair\(r, true\)/, "seen rows reuse the escaped row builder, project column included");
|
||||
- assert.match(html, /renderWaiting\(lastData\);\n\s*renderSeen\(lastData\);/, "renderAll must render the Seen section on every refresh");
|
||||
- assert.ok(m, "renderProjects() must exist in page.html");
|
||||
- assert.match(body, /if \(!\(project in hideSeenState\)\) hideSeenState\[project\] = true/, "Hide seen defaults to on and the choice survives re-render");
|
||||
- assert.match(body, /class="hide-seen-toggle" data-project="' \+ esc\(project\)/, "the checkbox carries the escaped project name");
|
||||
- assert.match(body, /hideSeen && r\.seen/, "seen rows are filtered when the box is ticked");
|
||||
- assert.match(body, /" seen hidden"/, "the note reports how many seen rows are hidden");
|
||||
- assert.match(html, /closest\("\.hide-offline-toggle, \.hide-seen-toggle"\)/, "one change handler serves both checkboxes");
|
||||
- assert.ok(m, "renderProjects() must exist in page.html");
|
||||
- assert.match(
|
||||
- assert.match(body, /<span>\(' \+ headCount \+ "\)<\/span>/, "the header uses headCount, not the raw group length");
|
||||
- assert.doesNotMatch(body, /<span>\(' \+ group\.length/, "the raw group length must no longer be rendered in the header");
|
||||
- assert.ok(m, "buildRowPair() must exist in page.html");
|
||||
- assert.match(body, /rec\.task\s*\?[\s\S]*esc\(rec\.task\)[\s\S]*:\s*'<span class="unknown">unknown<\/span>'/, "task is escaped, or reads unknown when null");
|
||||
- assert.match(body, /rec\.activeProject\s*\?[\s\S]*esc\(rec\.activeProject\)[\s\S]*unknown/, "active project is escaped, or reads unknown when null");
|
||||
- assert.match(body, /<dt>Task<\/dt>/, "detail lists the task");
|
||||
- assert.match(body, /<dt>Active project<\/dt>/, "detail lists the active project");
|
||||
- assert.match(body, /<dt>Workspace<\/dt><dd>" \+ \(rec\.workspace \? esc\(rec\.workspace\) : "unknown"\)/, "detail lists the workspace, escaped, or unknown");
|
||||
- assert.match(body, /var span = showProject \? 7 : 6/, "the detail row spans the two new columns");
|
||||
- assert.equal(heads.length, 3, "all three tables carry the two new headers");
|
||||
- assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
|
||||
- assert.ok(rowPair, "buildRowPair() must exist in page.html");
|
||||
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\)/, "the task cell shows its source");
|
||||
- assert.match(rowPair[0], /sourceTag\(rec\.activeProjectSource\)/, "the active project cell shows its source");
|
||||
- assert.match(
|
||||
- assert.match(rowPair[0], /esc\(rec\.agent\) \+ "<\/button>" \+ modelTag\(rec\)/, "the agent cell shows the model under the name");
|
||||
- assert.match(rowPair[0], /<dt>Model<\/dt><dd>" \+ modelText\(rec\) \+ "<\/dd>/, "the detail list has a Model row");
|
||||
- assert.ok(line, `${name}() must exist in page.html`);
|
||||
- assert.match(line, /esc\(rec\.model\)/, `${name}() escapes the model`);
|
||||
- if (name === "modelText") assert.match(line, /esc\(rec\.provider\)/, "modelText() escapes the provider");
|
||||
- assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
|
||||
- assert.equal(sourceLabel.registration, "registered", 'SOURCE_LABEL must map "registration" to "registered"');
|
||||
- assert.ok(sourceTagLine, "sourceTag() must exist in page.html");
|
||||
- assert.match(sourceTagLine, /esc\(source\)/, "sourceTag() escapes the raw source value (used in the title)");
|
||||
- assert.match(sourceTagLine, /esc\(sourceLabel\(source\)\)/, "sourceTag() escapes the label text it displays");
|
||||
- assert.ok(fromSourceLine, "fromSource() must exist in page.html");
|
||||
- assert.match(fromSourceLine, /esc\(sourceLabel\(source\)\)/, "fromSource() escapes the label text it displays");
|
||||
- assert.ok(registeredTextFn, "registeredText() must exist in page.html");
|
||||
- assert.match(line, /esc\(/, `every dynamic value read off reg must be escaped: ${line.trim()}`);
|
||||
```
|
||||
|
||||
### A8: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): every refusal code the reader can raise has an HTTP status
|
||||
|
||||
Reason: Moved in, not weakened: REFUSAL_STATUS table and code-count asserts from the retired conversation test (hunk 15), plus the DEFAULT_AGENT_SEND path asserts (hunk 12).
|
||||
|
||||
```diff
|
||||
+ assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
|
||||
+ assert.ok(existsSync(DEFAULT_AGENT_SEND));
|
||||
+ assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
|
||||
+ assert.ok(codes.size >= 15, [...codes].join(" "));
|
||||
+ assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
||||
+ assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
|
||||
+ assert.deepEqual(REFUSAL_STATUS, {
|
||||
```
|
||||
|
||||
### A9: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): POST /api/reply: a registration with a tmux socket adds -L <socket>
|
||||
|
||||
Reason: Re-indent only: the same -L socket asserts move into the board.reply closure (identical text after trim).
|
||||
|
||||
```diff
|
||||
- assert.equal(status, 200);
|
||||
- assert.equal(body.socket, "mosaic-fleet");
|
||||
- assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
|
||||
+ assert.equal(status, 200);
|
||||
+ assert.equal(body.socket, "mosaic-fleet");
|
||||
+ assert.equal(argv[argv.indexOf("-L") + 1], "mosaic-fleet");
|
||||
```
|
||||
|
||||
### A10: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called
|
||||
|
||||
Reason: Re-indent only: the refusal asserts move into the ported reply test; the tool-not-run check is unchanged.
|
||||
|
||||
```diff
|
||||
- assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
|
||||
- assert.match(body.error, c.error);
|
||||
- assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
|
||||
+ assert.equal(status, c.status, `${JSON.stringify(c.body).slice(0, 60)}: ${JSON.stringify(body)}`);
|
||||
+ assert.match(body.error, c.error);
|
||||
+ assert.equal(existsSync(f.capture), false, "the tool must not run on a refusal");
|
||||
```
|
||||
|
||||
### A11: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash
|
||||
|
||||
Reason: Re-indent only for the 500 and path asserts. The healthz status line is dropped with the HTTP server.
|
||||
|
||||
```diff
|
||||
- assert.equal(status, 500);
|
||||
- assert.match(body.error, /missing-agent-send\.sh/);
|
||||
- assert.equal(health.status, 200);
|
||||
+ assert.equal(status, 500);
|
||||
+ assert.match(body.error, /missing-agent-send\.sh/);
|
||||
```
|
||||
|
||||
### A12: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): replyToRow: DEFAULT_AGENT_SEND is the repository's tools/tmux/agent-send.sh and it is executable
|
||||
|
||||
Reason: Moved, not weakened: DEFAULT_AGENT_SEND asserts move to the refusal-table test (hunk 8).
|
||||
|
||||
```diff
|
||||
- assert.equal(DEFAULT_AGENT_SEND, resolve(pkgRoot, "..", "..", "tools", "tmux", "agent-send.sh"));
|
||||
- assert.ok(existsSync(DEFAULT_AGENT_SEND));
|
||||
- assert.ok(statSync(DEFAULT_AGENT_SEND).mode & 0o111);
|
||||
```
|
||||
|
||||
### A13: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped
|
||||
|
||||
Reason: Retired page.html source checks for the reply box (canReply, replyControl, postReply, receipts). Reply gate behaviour is covered in the Console by browser-edge.test (reply needs a registered seat, line 61; in-flight reply).
|
||||
|
||||
```diff
|
||||
- assert.ok(canReplyFn, "canReply() must exist");
|
||||
- assert.match(canReplyFn[0], /reg\.alive !== false/, "a stale registration gets no box");
|
||||
- assert.match(canReplyFn[0], /reg\.tmux && reg\.tmux\.session/, "a registration without a tmux session gets no box");
|
||||
- assert.ok(replyControlFn, "replyControl() must exist");
|
||||
- assert.match(replyControlFn[0], /if \(!canReply\(rec\)\) return '<span class="unknown">reply needs a registered seat<\/span>'/);
|
||||
- assert.match(replyControlFn[0], /class="reply-form" data-id="' \+ esc\(id\)/);
|
||||
- assert.match(replyControlFn[0], /value="' \+ esc\(replyDrafts\[id\] \|\| ""\)/, "an unsent draft is put back after a refresh");
|
||||
- assert.match(rowPair[0], /<dt>Reply<\/dt><dd>" \+ replyControl\(rec\) \+ "<\/dd>/);
|
||||
- assert.ok(receiptFn, "replyReceipt() must exist");
|
||||
- assert.match(line, /esc\(/, `every receipt value must be escaped: ${line.trim()}`);
|
||||
- assert.match(receiptFn[0], /esc\(r\.stderr \|\| r\.error/, "a failure shows the tool's stderr");
|
||||
- assert.ok(postReplyFn, "postReply() must exist");
|
||||
- assert.match(postReplyFn[0], /fetch\("\/api\/reply", \{\s*method: "POST",\s*headers: \{ "content-type": "application\/json" \}/);
|
||||
- assert.match(postReplyFn[0], /JSON\.stringify\(\{ agent: id, text: text \}\)/);
|
||||
- assert.match(postReplyFn[0], /replyReceipts\[id\] = body/);
|
||||
- assert.ok(lines.some((l) => l.includes('main.addEventListener("submit"')), "the form submits through one delegated handler");
|
||||
- assert.ok(lines.some((l) => l.includes('main.addEventListener("input"')), "drafts are captured as they are typed");
|
||||
- assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
|
||||
- assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
|
||||
+ // Ported from the writer's HTTP test (refactor 78b69e1e6): the same assertions over board.index().
|
||||
```
|
||||
|
||||
### A14: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): startServer: /api/board carries taskSetBy from a live registration and null for the derived rows
|
||||
|
||||
Reason: Ported: byAgent taskSource, taskSetBy and registrationErrors asserts kept on board.index() (test renamed 'index: carries taskSetBy...'). Retired: page.html tag and detail checks and the HTTP Host/Origin fences (no listener).
|
||||
|
||||
```diff
|
||||
- assert.equal(byAgent.agent1.taskSource, "registration");
|
||||
- assert.equal(byAgent.agent1.taskSetBy, "darkwing");
|
||||
- assert.equal(byAgent.agent2.taskSource, "first-user-message");
|
||||
- assert.equal(byAgent.agent2.taskSetBy, null);
|
||||
- assert.deepEqual(body.registrationErrors, []);
|
||||
- assert.ok(rowPair);
|
||||
- assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
|
||||
- assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
|
||||
- assert.ok(tagLine);
|
||||
- assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
|
||||
- assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
|
||||
- assert.match(tagLine, /not verified/);
|
||||
- assert.ok(textLine);
|
||||
- assert.match(textLine, /esc\(rec\.taskSetBy\)/);
|
||||
- assert.match(textLine, /not verified/);
|
||||
- assert.ok(canReplyFn);
|
||||
- assert.equal(canReplyFn[0].includes("taskSetBy"), false);
|
||||
- assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
|
||||
- assert.equal(board.status, 403, `GET /api/board, ${label}`);
|
||||
- assert.deepEqual(JSON.parse(board.text), { error }, `GET /api/board, ${label}`);
|
||||
- assert.equal(board.headers["access-control-allow-origin"], undefined);
|
||||
- assert.equal(posted.status, 403, `POST /api/reply, ${label}`);
|
||||
- assert.deepEqual(JSON.parse(posted.text), { error }, `POST /api/reply, ${label}`);
|
||||
- assert.equal(posted.headers["access-control-allow-origin"], undefined);
|
||||
- assert.equal((await rawRequest(port, {})).status, 400, "GET /api/board, missing Host");
|
||||
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/reply", headers: { "content-type": "application/json" }, body: reply })).status, 400, "POST /api/reply, missing Host");
|
||||
- assert.equal(existsSync(f.capture), false, "agent-send was never run");
|
||||
- assert.equal(existsSync(join(f.boardDir, "index.json")), false, "no refused request rescanned the board");
|
||||
- assert.equal((await rawRequest(port, { path, headers: { host: `rebind.example:${port}` } })).status, 403, path);
|
||||
- assert.equal((await rawRequest(port, { method: "POST", path: "/api/seen", headers: { host: `rebind.example:${port}`, "content-type": "application/json" }, body: "{}" })).status, 403);
|
||||
- assert.equal((await rawRequest(port, { headers: { host } })).status, 200, host);
|
||||
- assert.equal((await rawRequest(port, { headers: { host, origin: `http://${host}` } })).status, 200, `${host} with its own Origin`);
|
||||
- assert.equal(posted.status, 200);
|
||||
- assert.equal(JSON.parse(posted.text).delivered, true);
|
||||
- assert.equal((await fetch(`http://${own}/api/board`)).status, 200, "fetch without Origin");
|
||||
- assert.equal(r.status, 403, `${path}, ${label}`);
|
||||
- assert.deepEqual(JSON.parse(r.text), { error }, `${path}, ${label}`);
|
||||
- for (const h of Object.keys(r.headers)) assert.ok(!h.startsWith("access-control-"), `${path}, ${label}: ${h}`);
|
||||
- assert.deepEqual(calls, [], "the reader never ran for a refused request");
|
||||
- assert.equal((await rawRequest(port, { path: "/api/conversations", headers: { host: own, origin: `http://${own}` } })).status, 200, "same-origin passes");
|
||||
- assert.deepEqual(calls, ["catalogue"]);
|
||||
+ assert.equal(byAgent.agent1.taskSource, "registration");
|
||||
+ assert.equal(byAgent.agent1.taskSetBy, "darkwing");
|
||||
+ assert.equal(byAgent.agent2.taskSource, "first-user-message");
|
||||
+ assert.equal(byAgent.agent2.taskSetBy, null);
|
||||
+ assert.deepEqual(body.registrationErrors, []);
|
||||
```
|
||||
|
||||
### A15: control-board/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): conversation routes: a reader listing that refuses gets its refusal status and body, not a 200
|
||||
|
||||
Reason: Ported: status and refusal body kept on board.conversation (renamed 'conversation listing...'). Retired: HTTP content-type, nosniff, no-store and no-CORS asserts, now in the Console test (group B hunk 25). Retired: pi and claude catalogue and paging asserts, HTTP-only (gap).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 503);
|
||||
- assert.deepEqual(await res.json(), { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
|
||||
- assert.ok(codes.size >= 15, [...codes].join(" "));
|
||||
- assert.deepEqual([...codes].filter((c) => !(c in REFUSAL_STATUS)), []);
|
||||
- assert.deepEqual(Object.keys(REFUSAL_STATUS).filter((c) => !codes.has(c)), [], "no stale entries");
|
||||
- assert.deepEqual(REFUSAL_STATUS, {
|
||||
- assert.equal(res.headers.get("content-type"), "application/json", path);
|
||||
- assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
|
||||
- assert.equal(res.headers.get("cache-control"), "no-store", path);
|
||||
- assert.equal(res.headers.get("access-control-allow-origin"), null, path);
|
||||
- assert.equal(cat.status, 200);
|
||||
- assert.deepEqual([pi.seat, pi.title, pi.readOnly], ["agent1", "m0", true]);
|
||||
- assert.deepEqual([link.refusal, claude.seat, claude.unsupportedReason], ["unsafe-path", "rocko", "unsupported-harness"]);
|
||||
- assert.equal(first.status, 200);
|
||||
- assert.deepEqual([first.body.page.kind, first.body.page.entries.length, first.body.page.hasMore, first.body.page.branch], ["page", 100, true, "main"]);
|
||||
- assert.equal(second.status, 200);
|
||||
- assert.deepEqual([second.body.page.entries.length, second.body.page.hasMore], [20, false]);
|
||||
- assert.equal(second.body.page.entries.at(-1).content[0].text, "m119");
|
||||
- assert.deepEqual([follow.status, follow.body.page.entries.length], [200, 0]);
|
||||
- assert.equal(branch.status, 200);
|
||||
- assert.equal(r.status, status, path);
|
||||
- assert.deepEqual(r.body.refusal, { code, reconcile }, path);
|
||||
- assert.equal(typeof r.body.error, "string");
|
||||
- assert.equal(r.status, 400, path);
|
||||
- assert.deepEqual(treePrint(f.sessionsDir), before, "the sessions directory is unchanged");
|
||||
- assert.equal(existsSync(join(f.proj, ".pi", "state", "rocko")), false, "the Claude seat's directory was never created");
|
||||
+ assert.equal(out.status, 503);
|
||||
+ assert.deepEqual(out.body, { error: refusal.message, refusal: { code: refusal.code, reconcile: refusal.reconcile } });
|
||||
```
|
||||
|
||||
### A16: webui/tests/browser.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): served Console browser: real board fixtures, keyboard, drafts, receipts, themes, 320px and failures
|
||||
|
||||
Reason: CHANGED ASSERT: the error text must contain 'in the Console' instead of the board URL, since there is no board URL in-process. Not run; verify on CLEAR.
|
||||
|
||||
```diff
|
||||
- assert.ok(error.includes(f.boardURL)); assert.match(error, /Showing last known data/);
|
||||
+ assert.ok(error.includes('in the Console')); assert.match(error, /Showing last known data/);
|
||||
```
|
||||
|
||||
### A17: webui/tests/preview.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): loopback requests are unchanged in preview mode, and the bus views work with the board down
|
||||
|
||||
Reason: CHANGED ASSERT: a dead board read now returns error 'invalid-response' at 502 (consoleRead, webui/src/serve.mjs:230-234). Status kept; the board echo field is gone.
|
||||
|
||||
```diff
|
||||
- assert.equal(board.status, 502); assert.equal(board.body.board, dead);
|
||||
+ assert.equal(board.status, 502); assert.equal(board.body.error, 'invalid-response');
|
||||
```
|
||||
|
||||
### A18: webui/tests/reads.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): settings: release, board, loopback address, notifier binding name only
|
||||
|
||||
Reason: CHANGED ASSERT: settings.board is null, since the board is in-process (webui/src/serve.mjs:874, boardLabel).
|
||||
|
||||
```diff
|
||||
- assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, launch: undefined, at: undefined });
|
||||
+ assert.deepEqual({ ...body, launch: undefined, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: null, address: `${base}/`, launch: undefined, at: undefined });
|
||||
```
|
||||
|
||||
### A19: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): loopback host and board origin fail closed
|
||||
|
||||
Reason: Retired: boardURL() is removed with the HTTP board address, so its asserts go with it.
|
||||
|
||||
```diff
|
||||
- for (const u of ['https://127.0.0.1', 'http://example.com', 'http://127.0.0.1/path', 'http://user:[email protected]', 'http://127.0.0.1?x', 'http://127.0.0.1#x']) assert.throws(() => boardURL(u));
|
||||
- assert.equal(boardURL('http://localhost:7331'), 'http://127.0.0.1:7331');
|
||||
- assert.equal(boardURL('http://[::1]:7331'), 'http://[::1]:7331');
|
||||
```
|
||||
|
||||
### A20: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: BEHAVIOUR CHANGE, not a re-expression: the refactor forwarded this request and returned the board's 409. The Console now answers 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The oversized-body 400 is also dropped here; no Console test covers the 4096 limit (open gap).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 409); assert.equal(await res.text(), '{"error":"fixture refusal <unsafe>"}');
|
||||
- assert.deepEqual(requests.at(-1), { url: path, method: 'POST', body: raw });
|
||||
- try { assert.equal((await post(base, '/api/reply', raw)).status, 400); } catch (err) { if (raw.length < 4096) throw err; }
|
||||
+ assert.equal(res.status, 403, path); assert.equal((await res.json()).sent, false);
|
||||
```
|
||||
|
||||
### A21: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: Dropped: text/plain 400 (Console body() at serve.mjs:58 refuses non-JSON, mapped to 400 at :191; no test, open gap), the request-count checks (no HTTP hop) and the board-down 502 (board is in-process). Added: 405 for GET /api/reply and OPTIONS /api/board; 'the board never ran a write' check on f.captures.
|
||||
|
||||
```diff
|
||||
- assert.equal((await post(base, '/api/reply', '{}', { 'content-type': 'text/plain' })).status, 400);
|
||||
- assert.equal(requests.length, 2);
|
||||
- assert.equal((await fetch(base + '/api/board')).status, 502);
|
||||
- assert.equal(requests.length, 3);
|
||||
+ assert.equal(f.captures.length, 0, 'the board never ran a write');
|
||||
+ assert.equal((await fetch(base + '/api/reply')).status, 405);
|
||||
+ assert.equal((await fetch(base + '/api/board', { method: 'OPTIONS' })).status, 405);
|
||||
```
|
||||
|
||||
### A22: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: CHANGED ASSERT: the exact 404 body and refusal object become a want.status and want.body table. The table must match the same body (check in the file, not run).
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 404, path);
|
||||
- assert.equal(await res.text(), '{"error":"fixture unknown <x>","refusal":{"code":"unknown-branch","reconcile":true}}');
|
||||
- assert.deepEqual(requests.at(-1), { url: path, method: 'GET', body: '' });
|
||||
+ assert.equal(res.status, want.status, path);
|
||||
+ assert.deepEqual(await res.json(), want.body, path);
|
||||
```
|
||||
|
||||
### A23: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect
|
||||
|
||||
Reason: WEAKENED: the exact 'refused before the board' check (request count unchanged, last url /api/board) becomes a status >= 400 check. This drops the proof the board was not reached. Flagged for Mos.
|
||||
|
||||
```diff
|
||||
- assert.equal((await post(base, '/api/conversation?id=x', '{}')).status, 405);
|
||||
- assert.equal((await post(base, '/api/conversations', '{}')).status, 405);
|
||||
- assert.equal((await fetch(base + '/api/conversation?id=x', { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
- assert.equal(requests.length, before, 'refused before the board');
|
||||
- assert.equal(requests.at(-1).url, '/api/board');
|
||||
+ assert.ok((await fetch(base + '/api/board')).status >= 400);
|
||||
```
|
||||
|
||||
### A24: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): unreachable board reports URL; CLI rejects unsupported options
|
||||
|
||||
Reason: CHANGED ASSERT: the 502 with the board URL becomes 503 not-configured for /api/board and /api/conversations. No board URL appears in any response now.
|
||||
|
||||
```diff
|
||||
- assert.equal(res.status, 502); const result = await res.json(); assert.equal(result.board, url); assert.ok(result.error.includes(url));
|
||||
+ assert.equal((await fetch(base + '/api/board')).status, 503);
|
||||
+ assert.equal((await fetch(base + '/api/conversations')).status, 503);
|
||||
```
|
||||
|
||||
### A26: webui/tests/service.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): render carries every serve flag, the preview set included
|
||||
|
||||
Reason: CHANGED ASSERT: the expected ExecStart drops the --board flag (in-process board; webui/src/cli.mjs and scripts/webui-service.sh changed in commit 1).
|
||||
|
||||
```diff
|
||||
- assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --board http://127.0.0.1:7331 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
|
||||
+ assert.equal(execStart(r.stdout), `ExecStart=${process.execPath} ${REPO}/packages/webui/src/cli.mjs serve --port 7340 --business mosaic.dev_1 --public-host console.example.com --auth-header X-authentik-uid --allow-user ${UID}`);
|
||||
```
|
||||
|
||||
## Group B (added by row 198; Sage rules)
|
||||
|
||||
### B0: control-board/tests/board.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): cli serve refuses and points to the Console
|
||||
|
||||
Reason: New in row 198: cli serve refusal (cli.mjs:14) and the 'serve' retirement message, plus the markSeen order and answer asserts.
|
||||
|
||||
```diff
|
||||
+ import assert from "node:assert/strict";
|
||||
+ assert.deepEqual(events, ["write", "bump", "scan"]);
|
||||
+ assert.deepEqual(before.seen, []);
|
||||
+ assert.deepEqual(index.seen, ["proj/agent1"]);
|
||||
+ const board = createBoard({ specs, boardDir, isAlive, onEvent: (name) => events.push(name), exec: async () => assert.fail("no send on refusal") });
|
||||
+ assert.equal(out.status, 409);
|
||||
+ assert.equal(events.includes("send"), false);
|
||||
+ assert.equal(events.includes("bump"), false);
|
||||
+ assert.equal(r.status, 2);
|
||||
+ assert.match(r.stderr, /retired on 7331/);
|
||||
+ assert.match(r.stderr, /webui/);
|
||||
```
|
||||
|
||||
### B3: control-board/tests/relaunch.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): without the pid seam, a null-pid registration is undecided: kept, alive null, no relaunch
|
||||
|
||||
Reason: New in row 198: null-pid relaunch test. Correction: a null pid makes registeredAlive undecided (scan.mjs:372, 377, 381), not dead. The seam only affects relaunch; the default stays () => true.
|
||||
|
||||
```diff
|
||||
+ assert.notEqual(row.registered, null);
|
||||
+ assert.equal(row.registered.alive, null);
|
||||
+ assert.equal(row.relaunchedAt, null);
|
||||
```
|
||||
|
||||
### B25: webui/tests/serve.test.mjs
|
||||
|
||||
Enclosing test (first match, old or new side): conversation routes: nosniff, no-store, no CORS header, and every board refusal keeps its status
|
||||
|
||||
Reason: New in row 198: Console conversation headers test (nosniff, no-store, no CORS header) and the REFUSAL_STATUS table through the Console route.
|
||||
|
||||
```diff
|
||||
+ assert.equal(hostile, 403);
|
||||
+ assert.equal((await fetch(f.base + path, { headers: { origin: 'https://evil.example' } })).status, 403);
|
||||
+ assert.equal(res.status, status, `${code} ${path}`);
|
||||
+ assert.deepEqual(await res.json(), { error: code }, `${code} ${path}`);
|
||||
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff', path);
|
||||
+ assert.equal(res.headers.get('cache-control'), 'no-store', path);
|
||||
+ assert.equal(res.headers.get('access-control-allow-origin'), null, path);
|
||||
```
|
||||
|
||||
## Retired, not ported
|
||||
|
||||
One line per test name that exists on refactor and not in the current tree. Name-based check: `comm` on test names in the three files.
|
||||
|
||||
- `control-board/tests/serve.test.mjs`: startServer: refuses a non-loopback host with ConfigError, never opens a socket — Listener refusal. No listener remains in control-board.
|
||||
- `control-board/tests/serve.test.mjs`: startServer: serves page, healthz, and a rescanning /api/board — HTTP-only page, healthz and /api/board. No listener remains; page content is not re-asserted (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: startServer: a seatsDir registration overrides the row and index.registered reflects it — HTTP-only seatsDir override check. Not re-asserted in this port (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: startServer: /api/board returns 500 JSON with an error field when scan throws — HTTP error envelope. A throwing board read is covered by the preview dead-board test (502 invalid-response, hunk 17); the 500 shape is not re-asserted.
|
||||
- `control-board/tests/serve.test.mjs`: CLI: serve refuses a non-loopback host with exit 2 and a refused: message — serve is retired; the refusal is asserted by board.test.mjs 'cli serve refuses and points to the Console' (group B hunk 0).
|
||||
- `control-board/tests/serve.test.mjs`: CLI: live serve prints its URL and answers /healthz — serve is retired (cli.mjs:14). The writer's HTTP healthz and page are gone; no listener remains (see Board listeners).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: esc() escapes every HTML-significant character — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen marks a row; GET /api/board still shows it seen; seen:false clears it — Seen POST HTTP test removed, no port (no hunk, no phrase). markSeen write, bump, scan order is covered by board.test.mjs. Body-parsing gap below.
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen without a JSON content-type returns 400 and does not write a mark — Body-parsing gap. Console body() refuses non-JSON content-type (webui/src/serve.mjs:58) then 400 at :191. No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen with invalid JSON returns 400 — Body-parsing gap. Console answers 400 (webui/src/serve.mjs:191). No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen with a body over 4096 bytes returns 400 (or resets the connection) and writes no mark — Body-parsing gap. Console body() default max 4096 (webui/src/serve.mjs:56) answers 400 via :191. No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/seen with a missing agent returns 400 — Body-parsing gap. Console answers 400 with the error message (webui/src/serve.mjs:197). No Console test (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/board returns 405; PUT /api/seen returns 405 — Writer HTTP method refusal. Console 405s are asserted by group A hunk 21 (GET /api/reply, OPTIONS /api/board).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: seenControl() escapes rec.project/agent/lastActivity, and the POST uses a JSON content-type — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: has a collapsed Seen section that lists seen rows with the shared row builder — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: each project has a Hide seen checkbox (default on) beside Hide offline, with a hidden-count note — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: a project header reads \ — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: task and active project cells show their source via sourceTag(); the detail has a Registered row via registeredText(); SOURCE_LABEL maps registration to registered; every dynamic value in sourceTag/fromSource/registeredText is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: runs agent-send.sh with -s from the registration, -S <host>:control-board, -m text plus the fixed trailer, no -L on the default socket, MOSAIC_TMUX_SOCKET stripped; answers delivered with the exit code and both streams — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: a registration with a tmux socket adds -L <socket> — Name not carried over; the -L socket asserts stay (hunk 9, re-indent only).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: a non-zero tool exit is a 200 with delivered false, the exit code and the stderr verbatim — Renamed; the phrase is still present in the current tree (rename only, no assert hunk).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: refusals before the tool runs: empty or blank or long text 400, unknown row 404, no registration 409, stale registration 409, no tmux session 409, bad JSON 400; the tool is never called — Name not carried over; the refusal table stays (hunk 10, re-indent only).
|
||||
- `control-board/tests/serve.test.mjs`: POST /api/reply: a missing agent-send.sh is a 500 with the path in the error, not a crash — Name not carried over; the 500 and path asserts stay in the ported reply test (hunk 11).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: the reply box appears only where canReply() holds (live registration with a tmux session), the detail has a Reply row, the submit posts JSON to /api/reply, receipts and drafts survive a refresh, and every receipt value is escaped — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: startServer: /api/board carries taskSetBy from a live registration and null for the derived rows — Renamed 'index: carries taskSetBy...'; asserts ported (hunk 14).
|
||||
- `control-board/tests/serve.test.mjs`: page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it — page.html is deleted. Source-shape asserts (esc, Hide seen, reply gate, source tags) have no in-process target. Console app.js coverage NOT verified in this pass (open gap).
|
||||
- `control-board/tests/serve.test.mjs`: Host/Origin guard: GET /api/board and POST /api/reply refuse a foreign Host, a wrong port and a cross-origin Origin with 403 JSON, before any scan or send, and never send CORS headers — Writer HTTP fence. control-board opens no listener (see Board listeners). Console fences at webui/src/serve.mjs:713-717 and :726 covered by the F16 test.
|
||||
- `control-board/tests/serve.test.mjs`: Host/Origin guard: loopback names on this port are accepted, with or without a same-origin Origin — Writer HTTP fence; same reason as the other Host/Origin guard test.
|
||||
- `control-board/tests/serve.test.mjs`: conversation routes (F16): a foreign Host, a wrong port and a cross-origin Origin get 403 before the reader runs, with no CORS headers — Writer HTTP fence. Replaced by the Console F16 test (webui serve.test.mjs, 'conversation route: a non-local Host or a foreign Origin is refused 403').
|
||||
- `control-board/tests/serve.test.mjs`: conversation routes: a reader listing that refuses gets its refusal status and body, not a 200 — Renamed 'conversation listing: ...' and ported to board.conversation (hunk 15). HTTP header asserts moved to the Console test (group B hunk 25).
|
||||
- `control-board/tests/serve.test.mjs`: conversation routes: catalogue, first page, next page and follow over HTTP; refusals map to 4xx with their code; nothing is written — HTTP-only catalogue and paging. No in-process port; page-content asserts are not re-asserted (open gap).
|
||||
- `control-board/tests/discord.test.mjs`: server rescans connector discovery and refuses HTTP reply without transport — Renamed in place to 'board rescans connector discovery and refuses reply without transport'; HTTP fetch and 409 ported to board.index()/board.reply() (hunks 1-2).
|
||||
- `webui/tests/serve.test.mjs`: loopback host and board origin fail closed — boardURL() removed with the HTTP board address (hunk 19).
|
||||
- `webui/tests/serve.test.mjs`: proxy preserves exact request bytes, status and receipt, rejects forms and malformed JSON, never follows redirect — Writer HTTP proxy test; the Console has no proxy hop in-process. Changed and weakened asserts in hunks 20-23 (see Weakened).
|
||||
- `webui/tests/serve.test.mjs`: unreachable board reports URL; CLI rejects unsupported options — No board URL to report in-process; replaced by 503 not-configured asserts (hunk 24).
|
||||
|
||||
## Weakened and changed asserts (for Mos)
|
||||
|
||||
- Hunk 20: 409 becomes 403 (behaviour change, see below); the oversized-body 400 is dropped.
|
||||
- Hunk 21: the text/plain 400 and the request-count and board-502 checks are dropped.
|
||||
- Hunk 22: exact 404 body text becomes a table comparison.
|
||||
- Hunk 23: exact 'refused before the board' check becomes status >= 400.
|
||||
|
||||
## Behaviour changes to report
|
||||
|
||||
1. POST /api/reply in the Console: 403 when send is off or the seat is unnamed (webui/src/serve.mjs ~843-846). The refactor forwarded the request unconditionally (hunk 20).
|
||||
2. /api/settings reports `board: null` in-process (hunk 18).
|
||||
3. A dead board read returns 502 `invalid-response` (hunk 17).
|
||||
4. Null-pid correction: a null pid is undecided (scan.mjs:372, 377, 381), not dead. The default `() => true` only matters for relaunch, and the new relaunch test (group B hunk 3) covers that.
|
||||
5. creative-hidden.test.mjs: ported from `startServer` to `createBoard` and `board.conversation`. Assertions unchanged. `startServer` is no longer exported by control-board/src/serve.mjs.
|
||||
|
||||
## Board listeners after 198
|
||||
|
||||
Command: `grep -rn "createServer\|\.listen(\|startServer\|startBoard" packages/control-board/src`
|
||||
|
||||
Result: no matches (exit 1). control-board/src opens no listener.
|
||||
|
||||
CLI entry: packages/control-board/src/cli.mjs:14 refuses `serve` with `ConfigError` (RETIRED message, line 11). No listener.
|
||||
|
||||
Not in the board's code, for completeness: the Console (packages/webui/src/serve.mjs:1 imports `createServer`; :684 creates the server; :909 listens) hosts the board in-process. packages/webui/src/tester-socket.mjs:24 listens on a separate tester socket, not the board.
|
||||
|
||||
So the Host/Origin tests in control-board are retired on this basis. The Console's Host and Origin fences are covered by the F16 test in webui/tests/serve.test.mjs.
|
||||
|
||||
## Open items (not closed in this pass)
|
||||
|
||||
- page.html source checks are retired with no verified Console equivalent in app.js (open gap).
|
||||
- Seen POST body parsing (400 for invalid JSON, missing agent, 4096 limit, non-JSON content-type) has no board test and no Console test. Gap recorded; no new test added (sage-4373).
|
||||
- Catalogue and paging over HTTP, seatsDir registration override, and the 500 shape are not re-asserted (see retire list).
|
||||
- Not run: chat-replies-browser (line 71 `startServer({ port: 0, board: f.board })`) and the shell 'board unavailable' text (ReadError message path, serve.mjs:230-234). Verify on CLEAR.
|
||||
- The webui F16 test (serve.test.mjs:93) is a new test; the hunk map does not isolate it as its own hunk. Confirm its add lines are in group B hunk 25's range.
|
||||
- webui/tests/discord.test.mjs needs no isPidAlive seam: its registration uses pid `process.pid`.
|
||||
- conversation/tests/flows.test.mjs imports replyToRow from control-board/src/serve.mjs; the export still exists. Not run.
|
||||
- Syntax: `node --check` passes for all touched JS files (no tests run).
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
# Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458)
|
||||
|
||||
Launchpad, draft 1, 2026-10-25, on `launchpad/row255` off refactor 78b69e1e6. Brief
|
||||
`docs/plans/2026-10-21_bundled-pocket-id.md` (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6,
|
||||
REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here.
|
||||
|
||||
This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or
|
||||
dragon-lin, which keeps Authentik.
|
||||
|
||||
## Sources
|
||||
|
||||
- Q203 and O6 as the brief quotes them. PRD `docs/prd/mosaic-stack.md` REQ-WEB-2 (:902), REQ-INST-1 (:1509),
|
||||
REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6
|
||||
(`docs/plans/2026-09-26_lead-decisions.md:655-663`).
|
||||
- The Q175 design `agents/sage/work/q175-sso/DESIGN.md` sections 4 and 10, and the code row 192 landed:
|
||||
`packages/webui/src/sso-config.mjs` (the `console.sso` shape), `packages/webui/src/sso.mjs` (the slot read,
|
||||
the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332).
|
||||
- Researcher's note `agents/researcher/work/2026-10-04_vikunja-pocketid.md` part 2 (v2.17.0).
|
||||
- A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or
|
||||
run. The latest stable release is **v2.19.1** (2026-10-22). Source paths below are under
|
||||
`backend/internal/` at tag v2.19.1 in `github.com/pocket-id/pocket-id`, marked "P". Releases v2.18.0,
|
||||
v2.19.0 and v2.19.1 are not marked breaking.
|
||||
|
||||
## What the Console already needs from any provider
|
||||
|
||||
This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one
|
||||
`console.sso.providers` entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The
|
||||
Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The
|
||||
facts that drive the rest of the design:
|
||||
|
||||
- `publicOrigin` and every `issuer` must be https (`sso-config.mjs` `httpsURL`). A plain-http Pocket ID can't
|
||||
be a provider at all.
|
||||
- Each provider needs 1 to 64 `subjects`. A `console.sso` with no subject refuses to load. So the first
|
||||
person's subject has to exist before the Console starts with `--sso` (section 5).
|
||||
- The client authenticates with `client_secret_basic` (`sso.mjs:148`), so the client is confidential.
|
||||
- The slot is `<configDir>/secrets/mosaic-stack/<slot>`: a regular file, owned by the Console's uid, mode
|
||||
with no group or other bits, and outside the repo and every stack copy (`readSlot`).
|
||||
- `config.json` is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175
|
||||
section 4). This matters for sections 1 and 5.
|
||||
|
||||
## 1. The choice
|
||||
|
||||
The installer is REQ-INST-3's install command (Vikunja #432; today `stack-upgrade.sh` and the per-case
|
||||
install scripts). It takes one sign-in option per instance:
|
||||
|
||||
- `--oidc existing`. The user points the Console at one or more OIDC providers they already have. Nothing
|
||||
is installed. The user writes `console.sso` themselves, as Q175 step 4 does today. The installer writes
|
||||
nothing into `console.sso`; it prints the entry shape and the two URIs to register
|
||||
(`<publicOrigin>/auth/callback`, `<publicOrigin>/auth/signed-out`).
|
||||
- `--oidc bundled`. The stack installs Pocket ID as an install component (section 2), and
|
||||
`console.sso.providers` holds exactly one entry, `id: "pocket-id"`, whose `issuer` is the component's
|
||||
origin.
|
||||
- No option keeps today's behaviour: no `console.sso`, and the Console runs without `--sso`.
|
||||
|
||||
How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file
|
||||
or system config". It has to be the system config. The organization file can be written by agents through
|
||||
`organizationWriter`, which is the reason Q175 B1 put `console.sso` in config.json, and the same reason
|
||||
holds for a component that issues the Console's identities. So config.json gets one new optional key:
|
||||
|
||||
```json
|
||||
"components": {
|
||||
"pocket-id": {
|
||||
"release": "v2.19.1",
|
||||
"origin": "https://id.example.internal",
|
||||
"port": 1411
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The loader (`scripts/mosaic-config.mjs`) checks the shape: `release` is a `vX.Y.Z` tag, and the component script refuses one that
|
||||
differs from the pin file (section 2), `origin` is an https origin, `port` is 1024 to 65535. It also cross-checks: when
|
||||
`components["pocket-id"]` is present, `console.sso` must have exactly one provider, `id` `pocket-id`,
|
||||
`issuer` equal to `origin`. A config with `console.sso` providers and no `components` is the existing
|
||||
choice and loads as today.
|
||||
|
||||
**M1, approved by Mos with three conditions (mos-1666 item 3).** `components` is a new top-level config key.
|
||||
The loader refuses unknown keys today, so this is a config schema change. The conditions:
|
||||
|
||||
1. **Rollback trap.** A copy older than P1 refuses a config that has the key, the same trap Q175 section 4
|
||||
names for `console.sso`. P1 documents the order. On a rollback past P1, first take `components` out of
|
||||
config.json (restore the backup made before the key went in), then roll back the pointer. The STATE check
|
||||
after the rollback reads config.json and confirms the key is absent.
|
||||
2. **Strict inside the key.** The loader refuses any name under `components` other than `pocket-id`, and any
|
||||
field under `pocket-id` other than `release`, `origin` and `port`. Each refusal names the key path and
|
||||
quotes no value, as `checkConsole` does.
|
||||
3. **test-config.sh cases:**
|
||||
- a valid `components` loads;
|
||||
- a malformed one fails, naming the bad key path;
|
||||
- a config without `components` loads;
|
||||
- the cross-check refuses a `console.sso` whose provider doesn't match `origin`.
|
||||
|
||||
What each choice writes into `console.sso` is in section 5. For the bundled choice, Pocket ID is the only
|
||||
providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry".
|
||||
A user who later adds an existing provider moves to `--oidc existing` and removes the component (section 2).
|
||||
|
||||
## 2. The component, against REQ-INST-1
|
||||
|
||||
**Pinned.** A Pocket ID release is a set of raw binaries plus `checksums.txt` (sha256). From v2.16.0 there is
|
||||
an SPDX SBOM per binary, and the release workflow attests `checksums.txt` through Sigstore (`actions/attest`
|
||||
in `.github/workflows/release.yml`). The repo holds one pin file,
|
||||
`scripts/pocket-id-pins.sh`, sourced the way `scripts/case1-pins.sh` is: the tag, and for each supported platform (linux amd64 and arm64 to
|
||||
start) the asset name and its sha256. The build row copies the sha256 from `checksums.txt` at the tag and
|
||||
records the `gh attestation verify` output for each asset as its evidence. The pin is checked by hand at
|
||||
build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is
|
||||
not in any image either, since the container install comes after v1 (REQ-INST-3).
|
||||
|
||||
Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a
|
||||
minor every one to four weeks. Upgrading is a pin file change in a reviewed commit.
|
||||
|
||||
**Fetched and checked.** `scripts/component-pocket-id.sh fetch` downloads the pinned asset for the host's
|
||||
platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin.
|
||||
On a mismatch it deletes the file and refuses with `pocket-id-digest-mismatch`, naming the expected value
|
||||
and the computed one. On a match it installs the binary read-only (0555) at
|
||||
`<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`. The fetch is the only network step, and the user
|
||||
runs it at install. Tests never run it against the network (section 9).
|
||||
|
||||
**Runs.** A systemd user unit, `mosaic-pocket-id.service` (`mosaic-pocket-id-NAME.service` for a named
|
||||
instance, as row 190 names the bus and webui units), as the instance's user:
|
||||
|
||||
- `ExecStart=<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`.
|
||||
- `WorkingDirectory=<dataRoot>/components/pocket-id/run`, a directory the unit owns, so Pocket ID's habit of
|
||||
reading `.env` from the working directory reads nothing we didn't write. The build row checks there is no
|
||||
`.env` there at start.
|
||||
- Environment, all from the rendered unit:
|
||||
- `APP_URL=<origin>` and `HOST=127.0.0.1` (P `common/env_config.go`: HOST defaults to `0.0.0.0`).
|
||||
- `PORT=<port>`.
|
||||
- `DB_CONNECTION_STRING` and `UPLOAD_PATH` as absolute paths under `<dataRoot>/components/pocket-id/data/`
|
||||
(P defaults are `data/pocket-id.db` and `data/uploads`, relative to the working directory).
|
||||
- `ALLOW_USER_SIGNUPS=disabled` and `ALLOW_INSECURE_CALLBACK_URLS=false`.
|
||||
- `EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false` and `..._AS_ADMIN_ENABLED=false`.
|
||||
- The `OUTBOUND_ALLOWED_HOSTS_*` allowlists set empty (added in 2.19.0).
|
||||
- No `TRUST_PROXY` unless section 6's proxy step sets it to the proxy's address.
|
||||
- `ENCRYPTION_KEY` (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot
|
||||
`<configDir>/secrets/mosaic-stack/pocket-id-encryption-key`, 0600. The component script generates it from
|
||||
`/dev/urandom` straight into the file and never prints it. The unit passes it with
|
||||
`LoadCredential=` and `ENCRYPTION_KEY_FILE=%d/...`. The build row confirms `ENCRYPTION_KEY_FILE` at the pinned
|
||||
tag; if it isn't there, it refuses rather than putting the key in `Environment=`.
|
||||
- The unit never carries `STATIC_API_KEY`. That key exists only during the enroll run (section 4).
|
||||
|
||||
The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with
|
||||
`ENCRYPTION_KEY`), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist
|
||||
(`packages/seat/src/sandbox.mjs`), and `<dataRoot>/components/` is not on it. The build row adds a sandbox
|
||||
test that pins that.
|
||||
|
||||
**Health-checked.** `GET http://127.0.0.1:<port>/healthz` returns 204 (P `controller/healthz_controller.go`).
|
||||
`component-pocket-id.sh status` reports the unit state, the binary tag against the pin, the binary's sha256
|
||||
against the pin, and the health result. The Console's own `/healthz` already shows `sso: { "pocket-id": ready
|
||||
| unreachable | refused }` from its discovery retries, so an operator sees the provider from either side.
|
||||
|
||||
**Removable.** `component-pocket-id.sh remove` refuses while `console.sso` still names `pocket-id`, so the
|
||||
Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to
|
||||
`--oidc existing`), the script stops and removes the unit, then moves the data directory to
|
||||
`<dataRoot>/components/pocket-id.removed-<utc>` with a receipt line in `<dataRoot>/state/components.log`
|
||||
(append-only). It never deletes the passkey database outright. Deleting the moved directory and the
|
||||
encryption key slot is a separate `purge` verb that names both paths and needs `--yes`. Removing the
|
||||
`components` key from config.json is the user's edit.
|
||||
|
||||
## 3. The Q175 start checks against the bundled instance
|
||||
|
||||
The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs:
|
||||
|
||||
| Check (row 192) | Pocket ID v2.19.1 | Default that fails | Fix |
|
||||
|---|---|---|---|
|
||||
| `issuer` is https (loader) | issuer = `APP_URL` exactly (P `controller/well_known_controller.go`) | `APP_URL` defaults to `http://localhost:1411` | `APP_URL=<https origin>`, section 6 |
|
||||
| discovery `issuer` equals config `issuer` | equal when `APP_URL` has no trailing slash; Pocket ID refuses a path | none | config `issuer` = `origin`, no trailing slash; the loader cross-check compares them |
|
||||
| `id_token_signing_alg_values_supported` overlaps `algs` | advertises only the current key's alg. RS256 (RSA-2048) by default (P `service/jwt_service.go`) | none | `algs: ["RS256"]` |
|
||||
| `code_challenge_methods_supported` has `S256` | advertises `plain` and `S256` | none | none. The Console always sends S256 (`sso.mjs:221`) |
|
||||
| JWKS has a usable key for `algs` | one key at `/.well-known/jwks.json` | none | none. The build row's fake OP pins `use`/`alg`/`kid` as Pocket ID emits them |
|
||||
| `end_session_endpoint` is https | `<APP_URL>/api/oidc/end-session` | http, if `APP_URL` is http | same fix as row 1 |
|
||||
| `redirect_uri` is registered | exact match first, then wildcard patterns; loopback callbacks match any port (P `utils/callback_url_util.go`) | `ALLOW_INSECURE_CALLBACK_URLS` defaults to true | register exactly `<publicOrigin>/auth/callback`, with no `*`; set `ALLOW_INSECURE_CALLBACK_URLS=false` |
|
||||
| post-logout URI | per-client `logoutCallbackURLs`, same matching | none | register exactly `<publicOrigin>/auth/signed-out` |
|
||||
| ID token alg in `algs`, `iss` match | as above | none | none |
|
||||
|
||||
Two points the Console doesn't check, set on the client anyway:
|
||||
|
||||
- `pkceEnabled: true`. Then Pocket ID refuses an authorize request that has no `code_challenge`. Pocket ID has
|
||||
no per-client S256-only setting (P `oidc/provider.go` turns on `plain` for the server). That doesn't relax
|
||||
anything: the Console sends S256 every time, and `plain` stays available only to other clients.
|
||||
- `isPublic: false`, which matches `client_secret_basic`.
|
||||
|
||||
Key rotation. `pocket-id key-rotate --alg ...` replaces the one signing key (P `cmds/key_rotate.go`). After a
|
||||
rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with
|
||||
`algs`), and that is the intended fail-closed result. A rotation is a user step that updates `algs` in the
|
||||
same edit. The component script never rotates.
|
||||
|
||||
## 4. The client, and where its secret goes
|
||||
|
||||
The person running the install creates the client through `component-pocket-id.sh enroll`. No agent runs
|
||||
this verb. It refuses when any agent marker is in its environment: the `MARKERS` list in
|
||||
`packages/bus/src/human.mjs` (`MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP`, `CLAUDECODE` and the rest) that the human
|
||||
boundary uses. A test pins the refusal.
|
||||
|
||||
Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of
|
||||
them is a hazard:
|
||||
|
||||
- `/setup` and its backing `POST /api/signup/setup` are unauthenticated: whoever calls first becomes admin
|
||||
(P `usersignup/service.go`). Sandboxed agents share the host's network namespace (`sandbox.mjs` unshares
|
||||
user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin.
|
||||
- `STATIC_API_KEY` is a fixed admin key with no expiry (P `apikey/service.go`). It doesn't count as
|
||||
completing setup, so `/setup` stays open while it's set.
|
||||
|
||||
So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a
|
||||
**private network namespace** (`unshare --user --net`, or `bwrap --unshare-net` with the binary and data
|
||||
directory bound), where only enroll's own loopback can reach it:
|
||||
|
||||
1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside
|
||||
the namespace with `STATIC_API_KEY_FILE` pointing at it, plus the unit's environment.
|
||||
2. Wait for `/healthz` 204.
|
||||
3. Create the admin user, `POST /api/users` with `isAdmin: true` and the username and email given with
|
||||
`--admin-user` and `--admin-email`. Keep the returned `id`. That is the OIDC `sub` (P
|
||||
`oidc/claims_service.go` uses `user.ID`, a UUIDv4 set at creation, so it's stable).
|
||||
4. Create the Console client: client id `mosaic-console-<instance>`, callback and logout URLs exactly as in
|
||||
section 3, `pkceEnabled: true`, `isPublic: false`. Supply the secret instead of letting Pocket ID
|
||||
generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot
|
||||
`<configDir>/secrets/mosaic-stack/console-sso-pocket-id` (0600, `O_CREAT|O_EXCL`, not a symlink). It sends
|
||||
the same bytes in the create body over the namespace's loopback (`dto/oidc_dto.go` accepts a supplied
|
||||
secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins
|
||||
that it appears in no output.
|
||||
5. Mint a one-time access token for the admin, `POST /api/users/<id>/one-time-access-token`.
|
||||
6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a
|
||||
static key, so no admin key survives enroll.
|
||||
7. Start the real unit and wait for `/healthz` 204.
|
||||
8. Print the sign-in link `<origin>/lc/<token>` to the user's terminal (section 5), and the `console.sso`
|
||||
entry (section 5).
|
||||
|
||||
The build row confirms each route and body against the pinned tag's source before it writes the client (the
|
||||
upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll
|
||||
stops the process, removes the static key, leaves no slot file it created, and refuses with the step name.
|
||||
|
||||
The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's
|
||||
database (stored as a hash). The Console's `readSlot` reads it as it does any provider's.
|
||||
|
||||
## 5. The first person, and their subject in `console.sso`
|
||||
|
||||
Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this:
|
||||
|
||||
1. Enroll prints `<origin>/lc/<token>`. The token is valid for one hour and is a credential, so it goes only
|
||||
to the terminal of the person who ran enroll. It goes in no log and no file.
|
||||
2. The user opens it in a browser that can reach `origin` (section 6). Pocket ID signs them in and asks them
|
||||
to add a passkey. Without one they can't sign in again. If the hour passes, `pocket-id
|
||||
one-time-access-token <username>` (P `cmds/one_time_access_token.go`), run by the user against the data
|
||||
directory, prints a new link. `component-pocket-id.sh relink` wraps that call.
|
||||
3. Enroll already has the subject, the `id` from step 3 of section 4. It doesn't wait for a sign-in or read a
|
||||
`subject-unknown` log line.
|
||||
|
||||
How the subject gets into `console.sso`. config.json is user-authored and never auto-written. Two ways
|
||||
to respect that; I recommend the first:
|
||||
|
||||
- **R1 (recommended).** Enroll prints the full entry and writes nothing:
|
||||
|
||||
```json
|
||||
"console": { "sso": { "publicOrigin": "<publicOrigin>", "providers": [ {
|
||||
"id": "pocket-id", "issuer": "<origin>", "clientId": "mosaic-console-<instance>",
|
||||
"clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"],
|
||||
"subjects": [ { "sub": "<id>", "name": "<admin-user>" } ] } ] } },
|
||||
"components": { "pocket-id": { "release": "<tag>", "origin": "<origin>", "port": <port> } }
|
||||
```
|
||||
|
||||
The user pastes it into config.json, as with Q175 step 4. Rerunning `scripts/bootstrap.sh` (or
|
||||
`--instance NAME`) then validates it through the loader, including the section 1 cross-check, and writes
|
||||
nothing. Nothing changes in invariant 2.
|
||||
- **R2 (rejected, Sage sage-4401: R1 chosen).** `enroll --write-config` would write those two keys itself, with a backup of config.json first, and
|
||||
refuse if `console` or `components` were already present. That would be the first tool to modify an
|
||||
existing config.json, against canon invariant 2's "never overwritten" practice. Not built.
|
||||
|
||||
Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On
|
||||
Jason's instances, only his subject goes into `console.sso` (mos-1197). Further people are added with
|
||||
Pocket ID's admin UI (a user plus a one-time link), and their subjects go into `console.sso` by the user's
|
||||
edit, never by an agent. Pocket ID users who aren't in `subjects` can sign in to Pocket ID, but the Console
|
||||
refuses them `subject-unknown`, so REQ-WEB-2's "must map to an authorized human" holds.
|
||||
|
||||
## 6. Reach: origin, TLS and proxy
|
||||
|
||||
WebAuthn takes its RP id from `APP_URL`'s host name, and its allowed origin is `APP_URL`
|
||||
(P `webauthn/module.go`). Browsers allow passkeys only on a secure origin. The Console also needs https for
|
||||
both `publicOrigin` and `issuer`. Pocket ID serves plain http, and I found no TLS listener setting in its
|
||||
environment config. So:
|
||||
|
||||
- Pocket ID always binds `127.0.0.1:<port>`. That is the default, and the component never binds anything
|
||||
else (`HOST=127.0.0.1`; the status verb refuses a unit whose `HOST` differs).
|
||||
- `origin` is an https origin served by a TLS front that the user already has for the Console. The Console
|
||||
needs one anyway, since `publicOrigin` is https. Pocket ID goes behind the same front under **its own host
|
||||
name**, for example `id.<console host>`. Pocket ID doesn't support a path prefix (`APP_URL` must have no
|
||||
path). A separate host name also keeps Pocket ID's cookies apart from the Console's
|
||||
`__Host-mosaic_*` cookies; the two share cookies if they sit on one host name on different ports.
|
||||
- The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket
|
||||
ID would need a non-loopback bind, and that is a `network.expose` hard wait (REQ-WAIT-1), not a component
|
||||
option.
|
||||
- `TRUST_PROXY` is set to the front's address only, never `true`.
|
||||
- The Console fetches discovery and JWKS from `issuer` over https from the server side, so the Console's
|
||||
node has to trust the front's certificate. A public CA does that. A private CA needs
|
||||
`NODE_EXTRA_CA_CERTS` in the Console unit, which the build row adds as a render flag.
|
||||
|
||||
These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach
|
||||
the Console or Pocket ID, or who the host trusts:
|
||||
- J1: the DNS name for `origin`;
|
||||
- J2: the certificate for it, or a private CA and its trust in the browser and in `NODE_EXTRA_CA_CERTS`;
|
||||
- J3: the TLS front's route from `origin` to `127.0.0.1:<port>` (a Traefik change on Jason's hosts);
|
||||
- J4: any listener off loopback.
|
||||
|
||||
The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on
|
||||
loopback with an https `APP_URL` that nothing serves yet. Enroll's step 2 health probe goes straight to
|
||||
`127.0.0.1`, so enroll still works. The person can't add a passkey until J1 to J3 are done.
|
||||
|
||||
## 7. Agents
|
||||
|
||||
Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues
|
||||
nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials
|
||||
grant and no admin API key exist after enroll. Residuals, stated plainly:
|
||||
|
||||
- Agents can reach `127.0.0.1:<port>`, because the sandbox shares the host network. What they can reach there
|
||||
is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, `/setup` is
|
||||
closed because an admin exists, and there is no static key. A test of the rendered unit pins all four
|
||||
settings.
|
||||
- The data directory and the two slots are outside every sandbox bind (section 2).
|
||||
- A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's
|
||||
existing slot has the same exposure; this design adds no new class of it.
|
||||
|
||||
## 8. Instances
|
||||
|
||||
One Pocket ID per named instance, never shared:
|
||||
|
||||
- Each instance has one system config (row 190), and `components` and `console.sso` live in it. A shared
|
||||
Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that
|
||||
belong to another instance's config and data root.
|
||||
- Unit names, ports, data directory, slots and removal are all per instance (`mosaic-pocket-id-NAME`,
|
||||
`<dataRoot of NAME>/components/pocket-id`), so removing one instance's component can't break another's
|
||||
sign-in.
|
||||
- Each instance needs its own `origin` (section 6), so passkeys are per instance too. A person enrolls once
|
||||
per instance.
|
||||
- An instance that wants to use another instance's Pocket ID can still do it with `--oidc existing`. Then it
|
||||
is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it.
|
||||
|
||||
Two instances on one host each need their own `port`. The loader can't see other instances' configs, so
|
||||
`component-pocket-id.sh` refuses at fetch and enroll when the port is already bound on loopback.
|
||||
|
||||
## 9. Tests, stand-ins only
|
||||
|
||||
No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com,
|
||||
mosaic.woltje.com or 127.0.0.1:7332. The stand-ins:
|
||||
|
||||
- **Fake Pocket ID OP**, a node server like row 192's fake OP, shaped from v2.19.1:
|
||||
- the discovery fields `well_known_controller.go` emits (issuer = origin, `plain` and `S256`,
|
||||
`end_session_endpoint` at `/api/oidc/end-session`, the three auth methods);
|
||||
- one RS256 JWK at `/.well-known/jwks.json`;
|
||||
- a token endpoint that takes only `client_secret_basic`;
|
||||
- `/healthz` 204.
|
||||
|
||||
The Console starts against it under `--sso` with the section 5 entry, with no check changed. Negative
|
||||
cases:
|
||||
- an http issuer refuses at load;
|
||||
- an ES256-only JWKS against `algs: ["RS256"]` refuses at discovery;
|
||||
- an issuer with a trailing slash refuses on the cross-check.
|
||||
- **Fake admin API** on the same stand-in: `POST /api/users`, the client create, the one-time token, and a
|
||||
static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in.
|
||||
The tests pin:
|
||||
- the namespace argv;
|
||||
- the slot file's mode and `O_EXCL`;
|
||||
- that the secret appears in no stdout, stderr or argv;
|
||||
- the static key file gone after success and after a failure at each step;
|
||||
- the refusal under an agent marker in the environment.
|
||||
- **Fetch**: a local file served by a test https server (self-signed, trusted only by the test) with a
|
||||
matching pin, a mismatched pin and a truncated file.
|
||||
- **Unit render**: the environment table in section 2. `HOST=127.0.0.1`, no `STATIC_API_KEY`, no
|
||||
`ENCRYPTION_KEY` value in the text, and `ALLOW_USER_SIGNUPS=disabled`.
|
||||
- **Loader**: test-config.sh cases for the `components` key and the cross-check, and the copy-rollback note.
|
||||
- **Sandbox**: `<dataRoot>/components/` is in no bind list.
|
||||
|
||||
## 10. Build rows, in landing order
|
||||
|
||||
All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them
|
||||
runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host.
|
||||
|
||||
1. **P1, loader key.** `components["pocket-id"]` in `mosaic-config.mjs`, strict inside the key, and the
|
||||
cross-check against `console.sso` (section 1, M1 conditions from mos-1666 item 3). P1 also writes the
|
||||
rollback order into `docs/TOOLS.md` beside Q175's: remove `components` (restore the backup) before the
|
||||
pointer goes back, and the post-rollback STATE check confirms the key is absent from config.json. Gate:
|
||||
test-config.sh green with the four M1 cases.
|
||||
2. **P2, pin and fetch.** `scripts/pocket-id-pins.sh` with the build-time sha256 and the
|
||||
attestation evidence, and `component-pocket-id.sh fetch|verify`. Gate: the fetch tests against the local
|
||||
stand-in; the evidence file shows `gh attestation verify` output for each pinned asset.
|
||||
3. **P3, unit.** The unit template, `render|install|status|remove|purge`, and the encryption key slot. Gate:
|
||||
the render and remove tests, and the sandbox bind test.
|
||||
4. **P4, enroll.** The namespaced bootstrap, the client, the subject, `relink`, and printing the entry (R1),
|
||||
(R2 rejected). Gate: the enroll tests in section 9, and a secret-leak grep over every test log.
|
||||
5. **P5, Console against the fake Pocket ID.** The section 9 fake-OP start tests, and the
|
||||
`NODE_EXTRA_CA_CERTS` render flag for the Console unit. Gate: the webui suite, with no change to
|
||||
`sso.mjs` checks (a diff of `sso.mjs` and `sso-config.mjs` against the base shows none).
|
||||
6. **P6, installer option.** `--oidc existing|bundled` on REQ-INST-3's install command, calling P2 to P4 in
|
||||
order, and the docs: `docs/TOOLS.md`, the host run sheet's J1 to J4 steps. Gate: the installer's own
|
||||
suite against the stubs; the docs list J1 to J4 as the user's steps.
|
||||
|
||||
## Open items for review
|
||||
|
||||
- M1 approved by Mos with three conditions (mos-1666 item 3), written into section 1 and P1.
|
||||
- M2 dropped: Sage chose R1 and rejected R2 (sage-4401).
|
||||
- **Q1 (Webby):** the private network namespace for enroll (section 4). The other option is to start the
|
||||
real unit and race `/setup`, which I think is unsafe while agents share the host network.
|
||||
- **Q2 (Webby):** one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that
|
||||
way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling.
|
||||
- Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and
|
||||
client-credentials between Mosaic services. Neither is in Q203.
|
||||
Reference in new issue
Block a user