Compare commits

..
45 changed files with 1773 additions and 1354 deletions

No files matched your search

-132
View File
@@ -1,132 +0,0 @@
# Row 206 build notes (Launchpad): 193b2, thread shares (#1704)
Brief: docs/plans/2026-10-20_human-users-build.md, "Brief: 193b2, thread shares (schema 3l)"
(blob de3a6310b). Design: agents/morgana/work/queue-193/DESIGN.md at e2d161e2e (sha256 dd7fcb41…),
sections 4.3, 5.2-5.6 and 8. Reviewer: Duckworth. Assignment: sage-2789.
## Base and chain
- Started on aca16404c (sage-2789) on branch launchpad/row206.
- 206 chains on 275 (193b1b, viewer verbs and board refusals), which Morgana is building. I rebase
onto Morgana's 275 sha when it comes, and onto 253's landing under a new branch name.
- At this base a user binds only with a collaborator grant (`#userGrants`, USER_LEVEL). Viewer
sessions arrive with 275, so the viewer column of design 4.3 is not testable here (sage-2818
Q4: the viewer column waits on the 275 rebase; 275 moves once more for Duckworth's B1,
duckworth-0166). On the 275
rebase, USER_VERBS and the viewer gate merge with the three share verbs, and the share verbs
must refuse a viewer (design 4.3: share, unshare and shares are "refused" for viewers).
- The live `--migrate` (3l→3m on the running bus) is not part of this build. It is Mos's go under
Q173, with a backup first and a STATE report after (mos-1310 item 4).
## What changed
- Schema 3l→3m. `thread_shares` as design 5.2, its index and three append-only triggers, in
schema.sql and in the new step `src/migrations/3l-3m.sql` with the same SQL text. SCHEMA_SHA256
d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9, SCHEMA_VERSION 3m.
`tests/fixtures/schema-3l.sql` is the 3l schema.sql (sha256 cd68512212ce…).
- `thread_shares` joins `timedTables` (store.mjs), the broker's clock list, `COUNTED` in
scripts/stack-release.sh and `LISTED` in stack-release.test (T46). It has `at`, so it goes in
the counted list, not the untimed one.
- broker.mjs:
- `#readable(s, t)` holds rules 1-4 of design 5.4. `#shared` reads the latest row per thread and
target. `#covered` reads the access document once per call and covers nothing on a read
failure, as `#userGrants` does.
- `#threadRead` admits the owner (with `#grantedThread`) or `#readable`, and serves only
`thread.messages`. `#thread` keeps the owner check for every write verb.
- `thread.share`, `thread.unshare`, `thread.shares` (design 5.3). `thread.list {shared:true}`
adds `owner`; the plain list keeps its shape (5.5).
- `replies` leaves out user thread messages except those in threads `#readable` admits.
`trail` needed no change: it already filters through `#hiddenMessage`, which calls `#readable`.
- USER_VERBS gains the three share verbs. `thread.shares` joins `#processingGate`'s read list.
- packages/bus/README.md: the verb rows, a "Thread shares" section, and the schema line (v3m, the
new sha, 23 tables, 90 triggers). schema.test counts 89 triggers because it drops one first.
## Choices and departures (for Sage and Duckworth)
1. Label 3l→3m, not 3i-3j (design) or 3l (brief title). 264a took 3k-3l, and labels go by
landing order (sage-2789).
2. A share covers every session of the thread, and `session.list` stays owner-only (sage-2818
Q1, from Sage's Q7 ruling, agents/sage/work/queue-264/RULINGS.md:72-77). broker.mjs keeps its
"no shares (Q7)" refusal: a sharee reads any session it can name but can't list them. A test
holds the refusal (human `thread-not-owned`, reader `read-only`, user `access-refused`).
**Follow-up**, bound to whichever of row 193 or the share wiring lands second: wire the sharee's
`session.list`. The bar is T1 sessions line 7 and REQ-USER-4 (candidate PRD 1509c7cc5,
docs/prd/mosaic-stack.md:1027-1052): read-only, and it needs current grants, the thread
owner's included.
3. A reader session matches shares as the business human (sage-2818 Q2: accepted, for the human
principal only). Design 4.3 says reader sessions "stay owner-only", and 5.5 says "messages in
other users' unshared threads hidden" for human, reader and tester. The reader sees what the
owner sees, shares to the owner included. Q2 also found a gap in the first tip (16b9c6df6): an
agent session has no owner, but it matched a `*` share, so a shared user message showed in an
agent's trail. `#readable` now returns false unless the session is the business human, a
reader or a user. A test binds an assistant run to the shared thread and a chief run with no
thread, and checks that neither (nor the tester) sees the message after a `*` share and a share
to the owner.
4. Access is read per request (sage-2818 Q3). Design 5.6's "30 s timer" and `access.reload` have
no path at this base: runtime.mjs:263 passes `access: () => loadAccess(file, context)`, read on
every request. The tests cover the per-request read (removed, expired, out of scope and
unreadable), which is the stricter case.
5. Rule 3 asks the owner's grant for any level, like rule 2 (design: "still has a live grant
covering t"). A user owner lowered to viewer keeps the thread readable to its sharees.
6. An unshare checks no coverage, so an owner can end a share whose target lost its grant.
7. `thread.shares` for a user goes through `#grantedThread`, so it needs the collaborator grant
to still cover the thread, like share and unshare. Design 4.3 says "own threads" with no grant
note; at this base every user is a collaborator, so the two agree until 275.
## Tests
New: tests/thread-shares.test.mjs, 15 tests: the read flow and the write refusals, `*`, rule 2,
rule 3 (with trail for human and reader), latest-row state, share-not-covered, target checks,
lost owner coverage, tester and reader refusals (18 `action.refused` events, no rows), trail and
replies, sessions, the sharee's `session.list` refusal (Q1), agents and the tester matching no
share (Q2), the 3l→3m migration and the 3l engine's refusal of a 3m store.
Pins moved 3l→3m: account-, chief-of-staff, comment-, gitea-, grant-, name-, spec-, usage- and
thread-migration, sessions, schema (23 tables, 89 triggers) and user-principal (USER_VERBS).
The engine copies in account-, gitea-, grant- and comment-migration gain 3l-3m.sql;
comment-migration's future step becomes 3m→3n. `later3l` in account-, gitea- and grant-migration
also leaves out `thread_shares` objects, and the gitea and grant `rows()` lists leave out the
table, since each compares a store migrated through 3m against an older one.
## Mutants (design section 8 numbers)
Each runs on a copy of the candidate tree with one edit, under `timeout -k 10 300`. The named
test must fail on an assertion; a thrown error or a refusal before the assertion doesn't count.
- M2. In `#readable`, delete the rule 2 line (`if (s.user && !this.#covered(s.user, ...)) return
false;`). Killed by "mutant 2: a share never widens a grant" (bob reads after his grant moves to
p2) and by the `*` test (carol reads).
- M3. In `#readable`, delete the rule 3 line. Killed by "mutant 3" (the human reads A after
alice's grant is removed).
- M4. In `#shared`, replace the latest-row lookup with any share row:
`SELECT op FROM thread_shares WHERE thread=? AND target=? AND op='share' LIMIT 1`. Killed by
"mutant 4" (bob still reads after the unshare).
- M7. In `thread.send`, replace `this.#thread(s, a.thread)` with `this.#threadRead(s, a.thread)`.
Killed by the first test (bob's send to A must refuse `thread-not-owned`).
- M8. In `thread.share`, delete the `share-not-covered` line. Killed by "mutant 8" (the share
to user:carol must refuse).
- M9. In `thread.share`/`thread.unshare`, replace `this.#thread(s, a.thread)` with
`this.#threadRow(s, a.thread)`. Killed by the first test (bob's share of A to `*` must refuse).
- Q2 probe (not a design mutant). In `#readable`, delete the human-principal line
(`if (!s.human && !s.reader && !s.user) return false;`). Killed by "only a human principal
matches a share" (the agent's trail shows the message after the `*` share).
## Run limits
From 264's table (agents/gizmoduck/work/queue-264/BUILD.md, Run limits), each at least 3x the
longest recorded run. A timeout with no failed test goes to Sage first; a timeout in the shell
phase goes to Mos unplaced (mos-1275).
| Run | LIMIT |
|---|---|
| stack-release.test alone | 1800 s |
| each other touched file alone | 300 s |
| bus suite | 900 s |
| cli suite | 1800 s |
| each scripts/test-*.sh | 900 s |
| each mutant probe | 300 s |
## Runs
None yet. Node and https git wait for Sage's CLEAR (Gander holds the slot for row 253 run 5).
+139
View File
@@ -0,0 +1,139 @@
# Row 252 build notes: Console inbox Approve and Deny for grant requests
Seat: Scrooge. Reviewer: Webby. Issue #1753, Vikunja #435 (J2).
Brief: `docs/plans/2026-10-21_console-inbox-approve.md` (blob b41676d06).
Design: Gander's DESIGN.md at d218e14bd, approved by Webby at 5d8f0f4b5 (webby-0082) with manifest checks N1-N5.
Base: aca16404c on branch `scrooge/row252`. Rebase onto the row 253 tip under a new branch name once 253 lands.
## Status
- Code, tests and docs are written. No test has been run yet, because Gander holds the gate slot for row 253 run 5 and Sage ruled no node until a CLEAR.
- Everything in the Results section is empty until that run. Nothing below claims a pass.
## Q188
No agent creates a grant. Approve and Deny are human-only Console actions.
- Only a console cap reaches `decision.preview.console` and `decision.resolve.console`. A console cap reaches nothing else (console-decide `J1, Q188`).
- The socket refuses a console proof for any other verb before the proof check (`server.mjs`).
- A console cap binds only through `authenticateConsole`, which needs the console proof (section 2): the same-tree `console-cli.mjs` wrapper, its own nonce, a direct same-tree `serve` parent, and a configured owner subject from the spawn environment.
- `recordEvent` keeps `human.input` reserved, so no agent writes the record that lets a console close start the executor (console-decide `recordEvent keeps human.input reserved`).
- A `via console` close without its matching `human.input` approves nothing (console-decide `J14`).
## Manifest checks N1-N5
- **N1.** Every `console.sso` subject is an owner of the business. Name is display only.
- `cli/src/config.mjs` carries `{provider, iss, sub}` per subject into the boot as `consoleOwners`. No sso means no key.
- `bus/src/runtime.mjs` validates the list (array, at most 512, exactly three non-empty strings each) and passes it to `verifyPrincipal`.
- Tests: cli config.test `Row 252 (Webby N1)` and principal.test.
- **N2.** `human.input` is written in the same transaction, before `#approvedGrant`. The executor starts once, and a deny starts none.
- Tests: console-decide `N2, J13` checks the order by seq and that the grant runs once. `J15` checks that a deny starts nothing.
- **N3.** The close notice comes from `from_run 'human-console'`, and `public/bus.js` shows "via console". `landing` and `#protectedEnd` stay CLI-only.
- Tests: console-decide `N2, J13` checks the notice. decide-browser `J17` checks the close-elsewhere line.
- **N4.** `decision.resolve` is not a Console verb.
- `CONSOLE_VERBS` is exactly the two console verbs. The webui serve `BUS_VERBS` read list has no decision verb, and webui `cli.mjs` has no `decision.` string. `consoleCall` refuses anything outside `CONSOLE_VERBS`.
- Tests: console-decide `N4`, and decide-sso `N4`, which hits `/api/bus/decision.*` for 404 and covers the cli.mjs scan and the consoleCall refusals.
- **N5.** `readProcess` keeps the five `MOSAIC_BUS_CONSOLE_*` keys in the env allowlist (`bus/src/human.mjs`). The human proof and its spawn are unchanged.
## Design choices and tradeoffs
- **Webui imports from the broker.** `webui/src/bus.mjs` imports `CONSOLE_VERBS` from `bus/src/broker.mjs`, which loads the broker module into the Console at import.
- I chose one source for the verb list over a second copy that could drift.
- If Webby prefers the copy, the alternative is a literal list plus a test pinning it equal to `CONSOLE_VERBS`.
- **The Console transport shares humanCall's spawn.** `consoleCall` and `humanCall` share it as `transport()`, so `webui/src/bus.mjs` changed. The busCaller `business` default keeps `||` (the old falsy semantics), not `??`.
- **Confirmations are held in memory in the serve process.** Their lifetime is 2 minutes, they are single use, and they bind the session, decision, choice and digest. The cap is a limit that never drops a live confirmation.
- A restart loses all held confirmations. The user clicks again and gets a new preview. Nothing is written until the resolve.
- **No retry anywhere.** A resolve whose bus reply is lost answers `outcome-unknown`, the confirmation is spent, and the page says "Nothing was sent again."
## Old tests changed (for Mos, per mos-1272)
Mos ruled both changes allowed in mos-1321 (17:12:28Z), each with one condition.
- **shell.test condition:** reword the comment that opened "No Resolve control", since it is false once the row lands. It now says row 252's Approve and Deny is the one decision control, that its labels come from ACTIONS, and that the doesNotMatch keeps any literal label out of the markup. Webby checks the wording in review.
- **channel-proof condition:** a header comment is not enough, because human.mjs changes what readProcess keeps. In the review, Webby reads the human.mjs and webui bus.mjs diffs and states two things:
- the human proof's checks are unchanged
- only the console verifier reads the five MOSAIC_BUS_CONSOLE_* keys, and each case is covered by a named test in console-decide or decide-sso
- **Second row to land (252 or 239):** it recomputes the webui bus.mjs hash, adds its own header line, and its reviewer re-reads bus.mjs from the base to the combined tip.
- **`webui/tests/shell.test.mjs`**, write-boundary test. Allowed under mos-1321, with the comment reworded.
- The `writes` list gains the new `fetch(url, ...)` in `decidePost`. It is first in file order (bus.js:362).
- The `method:` count goes from 7 to 8.
- A new assertion pins `decidePost`'s callers to `url` plus the two literal `/api/decisions/${enc(a.id)}/...` routes.
- No existing entry was loosened.
- **`bus/tests/channel-proof.test.mjs`**, sha256 re-pins with a header comment:
- `bus/src/human.mjs` (N5 env keys) to bb13c146…c885.
- `webui/src/bus.mjs` (consoleCall and `transport()`) to 701f009e…5272.
- Any later edit to either file needs a re-pin.
- `bus/tests/principal.test.mjs`, `webui/tests/tester-listener.test.mjs` and `cli/tests/config.test.mjs` have new tests appended only.
## Overlap with row 239 (Honker, b0c28366f, sage-2835)
- Row 239 also re-pins `channel-proof.test.mjs`:
- `packages/webui/src/bus.mjs`, to 1eba9774…e5cf. That is the pin both rows change.
- `packages/cli/src/transport.mjs`, which is 239 only.
- `packages/bus/src/human.mjs` is a 252-only pin.
- The two rows also share source files: `bus/src/broker.mjs`, `bus/src/runtime.mjs`, `webui/src/bus.mjs` and `webui/src/serve.mjs`.
- Whichever row lands second rebases onto the first and recomputes the webui `bus.mjs` pin from the merged file. Both header comments are kept. It then reruns every touched test file whole.
- Mos needs to see that re-pin (re-pins need Mos).
## Mutant plan (design section 6, J1-J17)
Each mutant is one edit to the named source. Run the killing test, then restore and compare the sha256 against the pre-mutant hash with `git status` clean apart from the build. Kill rules are written before the runs.
| J | Mutant | Source | Expected kill |
|---|---|---|---|
| J1 | eligibility from the request, `#consoleResolution` returns non-null for any decision | broker.mjs | console-decide `J1, J2` (ineligible) |
| J2 | hardcode `approve = yes`, `deny = no` | broker.mjs | console-decide `J2` (go/stop, deny listed first) |
| J3 | owner from name or header, skip the owner match in verifyPrincipal | principal.mjs | principal.test `console requires…` |
| J4 | skip the serve-parent walk | principal.mjs | principal.test `console requires…` |
| J5 | accept the human nonce namespace | principal.mjs | principal.test `console requires…` |
| J6 | reuse the session from the confirmation, skip the live session check at resolve | decision-http.mjs | decision-http `session that ends`, decide-sso `listener gates` (new session) |
| J7 | keep the console cap bound after the call (skip `release` for kind console) | server.mjs | **open**: no new test pins release for a console proof. Before the run, check whether an existing server test kills it. If not, add one and name it in the report. |
| J8 | skip the same-origin check | decision-http.mjs | decision-http `gates`, decide-sso `listener gates` |
| J9 | resolve without a confirmation token | decision-http.mjs | decision-http `a confirmation then a resolve`, `bad body` |
| J10 | drop the choice, decision or session binding | decision-http.mjs | decision-http `binds the session, decision and choice` |
| J11 | skip the digest, closed or business check | broker.mjs | console-decide `J11`, `N2, J13` (second answer closed) |
| J12 | do not spend the confirmation | decision-http.mjs | decision-http `a second resolve refuses`, `confirmations… one use` |
| J13 | write via cli, or omit the subject in human.input | broker.mjs | console-decide `N2, J13` |
| J14 | `#approvedGrant` accepts via console without the human.input | broker.mjs | console-decide `J14` |
| J15 | start the executor on any resolved choice | broker.mjs | console-decide `J15` and `J2` (stop starts nothing) |
| J16 | `decide` true without sso or send | serve.mjs, public/bus.js | decide-sso `J16`, decide-browser `J16` |
| J17 | retry on outcome-unknown or auto-submit after preview | public/bus.js | decide-browser `J17` (post count stays at 7) |
## Manifest
Source:
- packages/bus/src/broker.mjs
- packages/bus/src/client.mjs
- packages/bus/src/console-cli.mjs (new)
- packages/bus/src/human.mjs
- packages/bus/src/principal.mjs
- packages/bus/src/runtime.mjs
- packages/bus/src/server.mjs
- packages/cli/src/config.mjs
- packages/webui/src/bus.mjs
- packages/webui/src/cli.mjs
- packages/webui/src/decision-http.mjs (new)
- packages/webui/src/public/bus.js
- packages/webui/src/serve.mjs
Tests:
- packages/bus/tests/channel-proof.test.mjs (re-pins)
- packages/bus/tests/console-decide.test.mjs (new)
- packages/bus/tests/principal.test.mjs
- packages/cli/tests/config.test.mjs
- packages/webui/tests/decide-browser.test.mjs (new)
- packages/webui/tests/decide-sso.test.mjs (new)
- packages/webui/tests/decision-http.test.mjs (new)
- packages/webui/tests/shell.test.mjs (old test changed, see above)
- packages/webui/tests/tester-listener.test.mjs
Docs:
- packages/webui/README.md
- agents/scrooge/work/queue-252/BUILD.md (this file)
## Results
Not run. At the first CLEAR:
1. Run each touched test file whole, with TMPDIR under /mnt/storage/scratch/tmp.
2. Run the bus, webui and cli suites.
3. Run the mutants above, then the drift check against refactor.
+7 -33
View File
@@ -240,12 +240,10 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon
| `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. |
| `project.list` | No args; human sessions only. The business's projects, oldest first. |
| `thread.create` | `{project,name}`; human sessions only. The thread belongs to the caller and to the business's one thread instance (Projects and threads, below). Returns `{id,instance}`. Refuses `project-not-found`, `thread-exists`, `thread-instance-missing` or `thread-instance-ambiguous`. |
| `thread.list` | `{project?,shared?}`; human sessions only. The caller's own threads, oldest first. With `shared: true` (row 206), the threads others own that the caller may read (Thread shares, below), each row adding `owner`. |
| `thread.list` | `{project?}`; human sessions only. The caller's own threads, oldest first. |
| `thread.open` | `{thread,session?}`; human sessions only. Records a `thread_opens` row, which asks the launcher to start the thread's session. Returns `{opened}`. |
| `thread.send` | `{thread,session?,body,class?}`; human sessions only. A message to the thread's instance, in that thread, with its `human.input` event as `request`. Returns `{id,request}`. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). A thread shared with the caller reads too, any session of it (row 206). Writes nothing. |
| `thread.share`, `thread.unshare` | `{thread,to}`; the thread's owner only (row 206, #1704). `to` is `user:<id>`, the business human's name or `*`; yourself refuses `invalid-request`. A share refuses `share-exists` when one is in force, and `share-not-covered` when a `user:` target has no live grant covering the thread. An unshare refuses `share-not-found` when none is. Each writes one `thread_shares` row. Returns `{thread,to,op}`. |
| `thread.shares` | `{thread}`; the thread's owner only. The shares in force, `[{to,at,by}]`, oldest first. Writes nothing. |
| `thread.messages` | `{thread,session?,after?,limit?}`; human sessions only. The session's messages oldest first, after the message `after` names (`message-not-found` when it isn't in the session), at most `limit` (default 50, 1 to 200). Writes nothing. |
| `session.list` | `{thread}`; human and tester sessions (row 264a, #1769). The thread's sessions oldest first, `[{id,at,name,predecessor,default}]`. Writes nothing. |
| `session.create` | `{thread,name,op}`; human and tester sessions. A new session of the thread, metadata only. A retry with the same `op` and name returns the first row; another name under that `op` refuses `session-op-conflict`, and a taken name `session-exists`. Returns `{id,thread,name}`. |
@@ -285,31 +283,7 @@ any run but the thread's current holder.
A user reaches only their own threads (REQ-USER-2): `thread.open`, `thread.send` and
`thread.messages` refuse `thread-not-found` for an unknown id and `thread-not-owned` for
another user's thread, unless it is shared with them (Thread shares, below).
### Thread shares (row 206, #1704)
Schema `3m` adds `thread_shares`, append-only: one row per `share` or `unshare`, and the latest
row per thread and target is the share's state. A thread's owner shares it read-only with a
user (`user:<id>`), the business human or everyone (`*`). The 3l→3m step only adds the table,
so every existing thread stays private to its owner.
A session may read a thread it doesn't own only when all four hold (row 193 design 5.4):
1. a share is in force to the caller's owner string or to `*`;
2. a user caller has a live grant, of any level, covering the thread's project and instance;
3. a thread owned by a user is still covered by that user's grant;
4. neither the caller nor the owner is the tester.
Only a human principal matches a share: the business human, a reader session as that human, or
a user. An agent session matches none, `*` included, even when it is bound to the thread.
A reader session reads as the business human. A sharee can't call `session.list` on the
thread; it stays owner-only until row 193's share wiring. The check runs on every request against the
access document the broker reads then, so a removed, expired or unreadable grant or an
unshare ends reads on the next request. Only `thread.messages` admits a sharee; `thread.open`,
`thread.send` and the share verbs refuse it `thread-not-owned`. `trail` and `replies` show a
user's thread messages to the owner and readers while the check holds. The tester can't call
the share verbs (`tester-refused`). Tests: `tests/thread-shares.test.mjs`.
another user's thread.
`threadsState(business)` is a trusted launcher API, like `bindLaunch`, not a socket verb.
Per thread it returns `{thread,instance,pending,unread,lastMessage,lastOpen,lastClaim,holder}`:
@@ -792,10 +766,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
## Schema and verification
`schema.sql` is v3m, SHA-256
`d5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9`.
It has 23 tables, 90 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3m`, WAL,
`schema.sql` is v3l, SHA-256
`cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a`.
It has 22 tables, 87 triggers and five views. Source digest and a trusted full
sqlite_master digest are checked at open, alongside schema version `3l`, WAL,
quick_check and foreign-key integrity. Missing metadata refuses; it never
reinitializes an existing file. Full synchronous WAL transactions either commit
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
-15
View File
@@ -478,18 +478,3 @@ CREATE TABLE gitea_grants (
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
-- Row 206 (193b2, #1704, row 193 design 5.2): a thread owner's read-only shares. The latest row per
-- (thread, target) is the share's state. Append-only.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+153 -137
View File
@@ -131,9 +131,10 @@ export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'm
// FED R3 (#414, federation design 3.5): the collaborator level is this exact list. Every other verb
// refuses a user access-refused before its own checks: owner verbs, board reads, message.*,
// decision.*, project files, spec.read, assistant.stop, launch.*, name.set and usage.*.
// Row 206 (193b2, row 193 design 4.3): and the share verbs on a user's own threads.
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
'thread.share', 'thread.unshare', 'thread.shares']);
export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['decision.preview.console', 'decision.resolve.console']);
// The level a user's grant must reach for USER_VERBS (R1's covers, morgana-0005).
const USER_LEVEL = 'collaborator';
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
@@ -468,7 +469,6 @@ export class Broker {
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
]) {
const at = store.get(`SELECT max(at) at FROM ${table}`).at;
if (at) {
@@ -514,7 +514,7 @@ export class Broker {
if (s.human || s.reader || s.tester) return;
if (!['inbox', 'agents', 'tasks', 'trail', 'decisions', 'replies', 'messages', 'state', 'usage',
'requirements', 'spec.read', 'project.list', 'project.files', 'project.file', 'library.file', 'library.status',
'thread.list', 'thread.messages', 'thread.shares', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'thread.list', 'thread.messages', 'names', 'instances', 'routes', 'hosts.status', 'grants.status',
'processing.status', 'role.release', 'usage.end'].includes(verb)) this.#processingWork();
}
reconcileProcessing() { this.#processingMirror(this.#processingState()); }
@@ -756,22 +756,12 @@ export class Broker {
return thread;
}
#thread(s, id) {
const t = this.#threadRow(s, id);
const t = this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business);
if (!t) fail('thread-not-found');
// REQ-USER-2: a user reaches only their own threads.
if (t.owner !== this.#owner(s)) fail('thread-not-owned');
return t;
}
#threadRow(s, id) {
return this.#store.get('SELECT * FROM threads WHERE id=? AND business=?', identifier(id), s.business) ?? fail('thread-not-found');
}
// Row 206 (193b2, design 5.4): the thread a read verb names, the caller's own or one #readable
// admits. Only thread.messages takes it; every write verb keeps #thread's owner check.
#threadRead(s, id) {
const t = this.#threadRow(s, id);
if (t.owner === this.#owner(s)) return this.#grantedThread(s, t);
if (!this.#readable(s, t)) fail('thread-not-owned');
return t;
}
// Row 177 (#1675): who owns the threads a session makes. A tester's are `tester`'s, never the human's.
#owner(s) {
return s.tester ? 'tester' : s.user ? 'user:' + s.user : s.human;
@@ -815,62 +805,10 @@ export class Broker {
);
return Boolean(t) && t.owner.startsWith('user:') && t.owner !== this.#owner(s) && !this.#readable(s, t);
}
// Row 206 (193b2, design 5.4): may a session read thread `t` it doesn't own? Rule 4 first: a
// tester's thread is never shared, and a tester is never a share's target and holds no grant. A
// reader session is the owner's board view, so it reads what is shared with the owner. Only a
// human principal (the business human, a reader as that human, a user) matches a share: an agent
// session, even one bound to the thread, matches none, `*` included (sage-2818 Q2).
// Row 193 design 5.4. Rule 1 needs a share in force, and there is no thread_shares table before
// 193b2 (schema 3j), so nobody reads another's thread yet. 193b2 adds the share rule and rules 2-4.
#readable(s, t) {
if (s.tester || t.owner === 'tester') return false;
if (!s.human && !s.reader && !s.user) return false;
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
// Rule 1: a share in force to the caller or to everyone.
if (!this.#shared(t.id, me) && !this.#shared(t.id, '*')) return false;
// Rule 2: a share never widens a grant. The owner needs none.
if (s.user && !this.#covered(s.user, s.business, t)) return false;
// Rule 3: a thread whose user owner has lost coverage is hidden from its sharees too.
if (t.owner.startsWith('user:') && !this.#covered(t.owner.slice(5), s.business, t)) return false;
return true;
}
// A share's state is its latest row for the thread and target (design 5.2, Duckworth N12).
#shared(thread, target) {
return this.#store.get('SELECT op FROM thread_shares WHERE thread=? AND target=? ORDER BY seq DESC LIMIT 1', thread, target)?.op === 'share';
}
// Does a live grant of any level cover thread `t` for `user`? One read of the access file, as
// #userGrants does; any failure to read covers nothing (design 5.6, H3).
#covered(user, business, t) {
let doc = null;
try {
doc = this.#access?.() ?? null;
} catch {
doc = null;
}
const now = new Date();
return (doc?.grants ?? []).some((g) => g.user === user &&
covers(g, { business, project: t.project, instance: t.instance }, now, { level: 'viewer' }));
}
// The threads another principal owns that the session may read, oldest first.
#readableThreads(s, project) {
const me = s.reader ? this.#business(s.business).human : this.#owner(s);
return this.#store.all(
"SELECT t.* FROM threads t WHERE t.business=? AND t.owner IS NOT ? AND (? IS NULL OR t.project=?) AND EXISTS(SELECT 1 FROM thread_shares x WHERE x.thread=t.id AND x.target IN (?,'*')) ORDER BY t.seq",
s.business,
this.#owner(s) || null,
project ?? null,
project ?? null,
me || null,
).filter((t) => this.#readable(s, t));
}
// Row 206 (design 5.3): a share's target, an owner string: `user:<id>`, the business human's
// name or `*`. A share with yourself is invalid.
#shareTarget(s, to) {
const b = this.#business(s.business);
if (typeof to !== 'string') fail('invalid-request');
const user = to.startsWith('user:') ? to.slice(5) : null;
if (!(to === '*' || to === b.human || (user !== null && ID_PATTERN.test(user) && !['human', 'tester', 'user', b.human].includes(user))))
fail('invalid-request');
if (to === this.#owner(s)) fail('invalid-request');
return to;
return false;
}
#agent(s) {
if (s.tester) fail('tester-refused');
@@ -1087,6 +1025,26 @@ export class Broker {
if (!this.#userGrants(s).length) fail('access-revoked');
return this.#cap(s);
}
// Row 252 (#1753, design section 2): the trusted Console transport invokes this only after
// verifyPrincipal checked the console-cli wrapper, its webui `serve` parent and the signed-in owner.
// The cap is the business human's with via 'console' and the owner subject. request() lets it reach
// CONSOLE_VERBS only, and #human refuses it, so no cli verb accepts it.
bindConsole(record) {
keys(record, ['business', 'human', 'subject'], ['business', 'human', 'subject']);
const b = this.#business(record.business);
const x = record.subject;
if (record.human !== b.human || !object(x) || Object.keys(x).length !== 3 ||
!['provider', 'iss', 'sub'].every((k) => typeof x[k] === 'string' && x[k].length > 0 && x[k].length <= 512))
fail('human-required');
return this.#cap({
business: record.business,
human: b.human,
via: 'console',
console: Object.freeze({ provider: x.provider, iss: x.iss, sub: x.sub }),
role: null,
run: null,
});
}
bindReader({ business }) {
this.#business(business);
return this.#cap({ business, reader: true, role: null, run: null, human: false });
@@ -1135,10 +1093,61 @@ export class Broker {
const raise = this.#raised(business, decision);
if (d?.action !== 'credential.mint' || d.route_to !== 'human' || !raise?.grant) return null;
const r = this.#closed(decision);
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.via !== 'cli' || r.by !== this.#business(business).human)
return null;
if (r?.op !== 'resolved' || r.choice !== raise.approvalChoice || r.by !== this.#business(business).human) return null;
// Row 252 (#1753, design section 3, J14): a console close counts only with its own human.input,
// which only the broker writes (recordEvent reserves the kind).
if (!(r.via === 'console' ? this.#consoleInput(business, decision, r) : r.via === 'cli')) return null;
return raise.grant;
}
// Row 252: the console answer's human.input for close row r. Same decision, same seq, same choice,
// no role or run, and a three-string owner subject.
#consoleInput(business, decision, r) {
const e = this.#store.get(
`SELECT body FROM events WHERE business=? AND kind='human.input' AND subject=? AND actor_role IS NULL AND actor_run IS NULL
AND json_extract(body,'$.via')='console' AND json_extract(body,'$.decisionSeq')=? ORDER BY seq LIMIT 1`,
business,
decision,
r.seq,
);
const x = e && JSON.parse(e.body);
return (
!!x &&
x.kind === 'answer' &&
x.decision === decision &&
x.op === 'resolved' &&
x.choice === r.choice &&
object(x.subject) &&
['provider', 'iss', 'sub'].every((k) => typeof x.subject[k] === 'string' && x.subject[k].length > 0)
);
}
// Row 252 (design section 1): a Console-eligible grant request, or null. Open is the caller's
// check. Eligible is route_to human, action credential.mint, a raise with a validated grant binding
// and exactly two distinct option keys, one of them approvalChoice. Approve is that key and deny is
// the other. The summary carries only the binding's method, agent, host and target projects. The
// digest binds the id, business, options, approval key and binding, so any change reads stale.
#consoleResolution(d) {
if (d.route_to !== 'human' || d.action !== 'credential.mint') return null;
const raise = this.#raised(d.business, d.id);
if (!object(raise?.grant) || typeof raise.grant.method !== 'string') return null;
const options = JSON.parse(d.options);
if (options.length !== 2 || options[0].key === options[1].key) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
if (!approve) return null;
const deny = options.find((o) => o.key !== raise.approvalChoice);
const g = raise.grant;
const grant = {
method: g.method,
...(typeof g.agent === 'string' ? { agent: g.agent } : {}),
...(typeof g.host === 'string' ? { host: g.host } : {}),
...(Array.isArray(g.target?.projects) ? { projects: g.target.projects.filter((p) => typeof p === 'string') } : {}),
};
return {
approve: { key: approve.key, text: approve.text },
deny: { key: deny.key, text: deny.text },
grant,
digest: sha256(JSON.stringify([d.id, d.business, options, raise.approvalChoice, raise.grant])),
};
}
// r6 X1: the executor's own spend. The caller owns the transaction. The consume record is the only
// guarded body with `decision` (#436b section 7), so #spent counts it.
#consumeGrant(business, decision, method) {
@@ -4177,6 +4186,9 @@ export class Broker {
grants = this.#userGrants(s);
if (!grants.length) fail('access-revoked');
}
// Row 252 (design section 2, N4): a console cap reaches the two console verbs only, and nothing
// else reaches them. decision.resolve stays off the list.
if (!!s.console !== CONSOLE_VERBS.includes(request.verb)) fail('human-required');
const args = request.args ?? {};
if (!object(args)) fail('invalid-request');
this.#secretCheck(args);
@@ -4699,6 +4711,7 @@ export class Broker {
const s = this.#session(cap);
try {
if (s.user) fail('access-refused');
if (s.console) fail('human-required');
this.#processingWork();
keys(request, ['verb', 'args'], ['verb']);
if (!TASK_VERBS.includes(request.verb)) fail('unknown-verb');
@@ -4737,6 +4750,7 @@ export class Broker {
let args;
try {
if (s.user) fail('access-refused');
if (s.console) fail('human-required');
keys(request, ['verb', 'args'], ['verb']);
this.#processingWork();
if (!verb) fail('unknown-verb');
@@ -4796,6 +4810,7 @@ export class Broker {
let a, entry, timeout, bound;
try {
if (s.user) fail('access-refused');
if (s.console) fail('human-required');
this.#processingWork();
keys(request, ['verb', 'args'], ['verb']);
if (!HOST_VERBS.includes(request.verb)) fail('unknown-verb');
@@ -5112,15 +5127,38 @@ export class Broker {
}
return this.#decisionView(this.#decision(s, did));
}
// Row 252 (#1753, design section 3): the Console's read of one grant request before its
// confirmation. It returns the two answers, a safe grant summary and the digest the answer must
// carry. Anything not eligible refuses, so the browser never decides eligibility (J1).
case 'decision.preview.console': {
keys(a, ['id'], ['id']);
identifier(a.id);
if (!s.console || s.human !== b.human) fail('human-required');
const d = this.#decision(s, a.id);
if (this.#closed(d.id)) fail('decision-closed');
const c = this.#consoleResolution(d);
if (!c) fail('decision-not-eligible');
return { id: d.id, business: d.business, question: d.question, ...c };
}
case 'decision.resolve.console':
case 'decision.resolve':
case 'decision.seen':
case 'decision.withdraw': {
keys(a, ['id', 'choice', 'note'], ['id']);
const viaConsole = verb === 'decision.resolve.console';
if (viaConsole) keys(a, ['id', 'choice', 'digest'], ['id', 'choice', 'digest']);
else keys(a, ['id', 'choice', 'note'], ['id']);
identifier(a.id);
if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id);
if (this.#closed(d.id)) fail('decision-closed');
if (verb === 'decision.withdraw') {
// Row 252 (design section 3): the Console answer rechecks eligibility, the digest and the choice
// in this transaction. It answers one of the two keys, never a key the browser names (J2).
if (viaConsole) {
if (!s.console || s.human !== b.human) fail('human-required');
const c = this.#consoleResolution(d);
if (!c) fail('decision-not-eligible');
if (a.digest !== c.digest || ![c.approve.key, c.deny.key].includes(a.choice)) fail('decision-stale');
} else if (verb === 'decision.withdraw') {
if (s.human) {
this.#human(s);
} else {
@@ -5132,32 +5170,39 @@ export class Broker {
this.#agent(s);
if (s.role !== d.route_to) fail('not-resolver');
}
const op = verb === 'decision.resolve' ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
fail('invalid-choice');
this.#store.run(
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
d.id,
this.#now(),
op,
by,
op === 'resolved' ? a.choice : null,
a.note ?? null,
s.human ? 'cli' : 'broker',
const seq = Number(
this.#store.run(
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
d.id,
this.#now(),
op,
by,
op === 'resolved' ? a.choice : null,
a.note ?? null,
s.console ? 'console' : s.human ? 'cli' : 'broker',
).lastInsertRowid,
);
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
// transaction commits. It rechecks all of this itself, so a rollback starts nothing.
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
// Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's
// record to start the executor. Its body names the owner subject and the close's seq.
if (s.human)
this.#event(
s,
'human.input',
{ kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
s.console
? { kind: 'answer', decision: d.id, op, choice: a.choice, via: 'console', subject: s.console, decisionSeq: seq }
: { kind: 'answer', decision: d.id, op, choice: a.choice ?? null },
d.id,
);
// Row 234 (#1740, r6 X1): the human's cli approve of a grant mint starts the executor once this
// transaction commits. It rechecks all of this itself, so a rollback starts nothing. Row 252: a
// Console approve does too. A deny closes the decision with the other key and starts nothing.
const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null;
if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id));
if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id));
if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id));
// Row 115 (#1609): a close by anyone but the raising run tells the raising role, routed like message.send's.
// Row 133 (#1627): no messaging check here. decision.raise refused a route that can't message the
// raiser, definitions change only with a pinned copy and arbiters only through the business
@@ -5184,7 +5229,7 @@ export class Broker {
this.#now(),
s.business,
s.human ? 'human' : s.role,
s.human ? 'human-cli' : s.run,
s.console ? 'human-console' : s.human ? 'human-cli' : s.run,
d.raised_by_role,
'DECISION',
null,
@@ -5194,7 +5239,8 @@ export class Broker {
);
this.#route(s.business, mid, d.raised_by_role, session);
}
return grant ? { id: d.id, op, grant: 'started' } : { id: d.id, op };
const answer = viaConsole ? { id: d.id, op, choice: a.choice } : { id: d.id, op };
return grant ? { ...answer, grant: 'started' } : answer;
}
case 'message.send': {
keys(a, ['to', 'body', 'class', 'in_reply_to', 'corrects', 'decision'], ['to', 'body']);
@@ -5537,7 +5583,12 @@ export class Broker {
s.business,
s.human || s.reader ? 'human' : s.role,
)
.map((d) => this.#decisionView(d));
.map((d) => {
// Row 252: a grant request the Console can answer carries its two answers. The Console
// shows buttons from this, and the answer path rechecks it at the bus.
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
});
if (verb === 'agents') {
const claims = this.#store.all(
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
@@ -5814,13 +5865,8 @@ export class Broker {
// and the tester's lists only its own (row 175 listed it the business human's).
case 'thread.list': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project', 'shared']);
keys(a, ['project']);
if (a.project !== undefined) identifier(a.project);
if (a.shared !== undefined && a.shared !== true) fail('invalid-request');
// Row 206 (193b2, design 4.3): another's threads shared with the caller, each naming its
// owner. The plain list keeps its shape.
if (a.shared)
return this.#readableThreads(s, a.project).map((t) => ({ id: t.id, at: t.at, project: t.project, instance: t.instance, name: t.name, owner: t.owner }));
// FED R3 (design 3.5): a user's own threads that its grants still cover.
return this.#store.all(
'SELECT id,at,project,instance,name FROM threads WHERE business=? AND owner=?' +
@@ -5871,12 +5917,11 @@ export class Broker {
// A user's message, like a tester's, is never the owner's authority evidence (design 3.5).
return { id: mid, request: s.tester || s.user ? null : this.#event(s, 'human.input', { kind: 'instruction', message: mid }, mid) };
}
// The thread's history, oldest first. Reading writes nothing. Row 206 (193b2): a thread shared
// with the caller reads too, every session of it, since a share is the whole thread's.
// The thread's history, oldest first. Reading writes nothing.
case 'thread.messages': {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['thread', 'session', 'after', 'limit'], ['thread']);
const t = this.#threadRead(s, a.thread);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const session = this.#threadSession(t, a.session);
const limit = a.limit ?? THREAD_PAGE;
if (!Number.isInteger(limit) || limit < 1 || limit > THREAD_PAGE_MAX) fail('invalid-request');
@@ -5895,34 +5940,6 @@ export class Broker {
after,
);
}
// Row 206 (193b2, #1704, design 5.3): the owner of a thread shares it read-only, or ends a share.
// A user's collaborator grant must still cover the thread, and a user target needs a live grant
// of any level that covers it. Each writes one row; the latest row per target is the state.
case 'thread.share':
case 'thread.unshare': {
if (!s.user) this.#human(s);
keys(a, ['thread', 'to'], ['thread', 'to']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
const to = this.#shareTarget(s, a.to);
const op = verb === 'thread.share' ? 'share' : 'unshare';
if (op === 'unshare' && !this.#shared(t.id, to)) fail('share-not-found');
if (op === 'share') {
if (to.startsWith('user:') && !this.#covered(to.slice(5), s.business, t)) fail('share-not-covered');
if (this.#shared(t.id, to)) fail('share-exists');
}
this.#store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', this.#now(), s.business, t.id, op, to, this.#owner(s));
return { thread: t.id, to, op };
}
// The shares in force on the caller's own thread, oldest first.
case 'thread.shares': {
if (!s.user) this.#human(s);
keys(a, ['thread'], ['thread']);
const t = this.#grantedThread(s, this.#thread(s, a.thread));
return this.#store.all(
"SELECT target AS \"to\",at,by FROM thread_shares x WHERE thread=? AND op='share' AND seq=(SELECT max(seq) FROM thread_shares WHERE thread=x.thread AND target=x.target) ORDER BY seq",
t.id,
);
}
// Row 264a (#1769, T1 item 7): a thread's sessions, oldest first, and which one is the default.
// Each user reaches only their own threads' sessions (REQ-USER-2); there are no shares (Q7).
case 'session.list': {
@@ -5958,15 +5975,14 @@ export class Broker {
if (!this.#store.get('SELECT id FROM messages WHERE id=? AND business=?', a.id, s.business))
fail('message-not-found');
// Row 205 (design 5.7): a hidden id fails as an unknown one does. Hidden replies are left out
// in the query, so a page of them doesn't come back short. Row 206 (193b2): the threads
// #readable admits are the share exception.
// in the query, so a page of them doesn't come back short; #readable is false until 193b2,
// which adds the share exception to this clause too.
if (this.#hiddenMessage(s, a.id)) fail('message-not-found');
return this.#store.all(
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ? AND t.id NOT IN (SELECT value FROM json_each(?))) ORDER BY seq LIMIT ${REPLIES}`,
`SELECT * FROM messages m WHERE business=? AND in_reply_to=? AND NOT EXISTS(SELECT 1 FROM message_threads mt JOIN threads t ON t.id=mt.thread WHERE mt.message=m.id AND substr(t.owner,1,5)='user:' AND t.owner IS NOT ?) ORDER BY seq LIMIT ${REPLIES}`,
s.business,
a.id,
this.#owner(s) || null,
JSON.stringify(this.#readableThreads(s).map((t) => t.id)),
);
}
default:
+3 -2
View File
@@ -1,18 +1,19 @@
import { connect } from 'node:net';
import { BusError, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
export class Client {
constructor({ path, cap, human, tester, user, timeout = 5000 }) {
constructor({ path, cap, human, tester, user, console: owner, timeout = 5000 }) {
this.path = path;
this.cap = cap;
this.human = human;
this.tester = tester;
this.user = user;
this.console = owner;
this.timeout = timeout;
}
call(verb, args = {}) {
const request =
JSON.stringify({
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : { cap: this.cap }),
...(this.human ? { human: this.human } : this.tester ? { tester: this.tester } : this.user ? { user: this.user } : this.console ? { console: this.console } : { cap: this.cap }),
verb,
args,
}) + '\n';
+41
View File
@@ -0,0 +1,41 @@
// Row 252 (#1753, design section 2): the Console owner's transport shim, spawned only by the webui
// `cli.mjs serve` process for one preview or one answer. Not a second product CLI. The Console sets
// the nonce, the business and the signed-in (provider, iss, sub) in the spawn environment, and the
// bus reads them back from this process's initial environment. Nothing respawns here, so the
// Console stays the direct parent. Input is a verb and its args only: a body that names a business,
// a subject or any other key is refused, never merged.
import { readFileSync } from 'node:fs';
import { Client } from './client.mjs';
import { readProcess } from './human.mjs';
import { CONSOLE_VERBS } from './broker.mjs';
const env = process.env;
try {
if (!/^[a-f0-9]{64}$/.test(env.MOSAIC_BUS_CONSOLE_NONCE ?? '')) throw Object.assign(Error(), { code: 'human-required' });
const request = JSON.parse(readFileSync(0, 'utf8'));
if (
!request ||
typeof request !== 'object' ||
Array.isArray(request) ||
Object.keys(request).some((k) => !['verb', 'args'].includes(k)) ||
!CONSOLE_VERBS.includes(request.verb)
)
throw Error();
const me = readProcess(process.pid);
const client = new Client({
path: process.argv[2],
console: {
business: env.MOSAIC_BUS_CONSOLE_BUSINESS,
provider: env.MOSAIC_BUS_CONSOLE_PROVIDER,
iss: env.MOSAIC_BUS_CONSOLE_ISS,
sub: env.MOSAIC_BUS_CONSOLE_SUB,
pid: me.pid,
startTime: me.startTime,
nonce: env.MOSAIC_BUS_CONSOLE_NONCE,
},
});
const result = await client.call(request.verb, request.args ?? {});
process.stdout.write(JSON.stringify(result) + '\n');
} catch (e) {
process.stderr.write((/^[a-z-]{1,64}$/.test(e.code ?? '') ? e.code : 'invalid-request') + '\n');
process.exitCode = 2;
}
+5 -1
View File
@@ -10,6 +10,10 @@ const MARKERS = [
'CODEX_THREAD_ID',
'PI_AGENT_DIR',
];
// Row 252 (#1753, Webby N5): the Console wrapper's nonce and the subject its spawn bound. The
// verifier reads each from the wrapper's initial environment, never from its proof alone.
export const CONSOLE_ENV = Object.freeze(['MOSAIC_BUS_CONSOLE_NONCE', 'MOSAIC_BUS_CONSOLE_BUSINESS', 'MOSAIC_BUS_CONSOLE_PROVIDER', 'MOSAIC_BUS_CONSOLE_ISS', 'MOSAIC_BUS_CONSOLE_SUB']);
const NONCE_KEYS = ['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE', ...CONSOLE_ENV];
const refuse = () => {
throw new BusError('human-required');
};
@@ -31,7 +35,7 @@ export function readProcess(pid, { readFile = readFileSync, stat = lstatSync } =
for (const entry of environment.split('\0')) {
const i = entry.indexOf('=');
const key = entry.slice(0, i);
if (['MOSAIC_BUS_CLI_NONCE', 'MOSAIC_BUS_USER_NONCE'].includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
if (NONCE_KEYS.includes(key) || MARKERS.includes(key)) env[key] = entry.slice(i + 1);
}
return {
pid,
-15
View File
@@ -1,15 +0,0 @@
-- Row 206 (193b2, #1704, row 193 design 5.2): thread shares. Only a new table, its index and its
-- append-only triggers; no existing row changes, so every thread stays private to its owner.
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
CREATE TRIGGER thread_shares_no_update BEFORE UPDATE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_delete BEFORE DELETE ON thread_shares BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
CREATE TRIGGER thread_shares_no_replace BEFORE INSERT ON thread_shares WHEN EXISTS (SELECT 1 FROM thread_shares WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_shares is append-only'); END;
+38 -2
View File
@@ -5,8 +5,8 @@ import { verifyTester, testerNonce } from './tester.mjs';
// Trusted transport API. Identity is held only by this connection's transient session.
// User mapping and live grant authorization belong to R3, not to this verifier.
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce }) {
const kinds = ['human', 'tester', 'user'].filter(k => request?.[k]);
export function verifyPrincipal(request, { peer, paths, businesses, launches = [], readProcess = defaultRead, readTesterNonce = testerNonce, consoleOwners = [] }) {
const kinds = ['human', 'tester', 'user', 'console'].filter(k => request?.[k]);
if (kinds.length !== 1 || request.cap) throw new BusError('invalid-envelope');
const kind = kinds[0], proof = request[kind];
const error = kind === 'tester' ? 'tester-required' : 'human-required';
@@ -16,6 +16,7 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
const { business } = verifyTester(proof, { cliPath: paths.tester, launches, readProcess, readNonce: readTesterNonce });
return { kind: 'tester', business, id: 'tester' };
}
if (kind === 'console') return verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners });
let checked = proof, parent;
if (kind === 'user') {
if (!proof || typeof proof.user !== 'string' || !proof.user ||
@@ -42,3 +43,38 @@ export function verifyPrincipal(request, { peer, paths, businesses, launches = [
if (!id) throw new BusError('unknown-business');
return { kind: 'owner', business, id };
}
// Row 252 (#1753, design section 2): the Console owner. The same-tree console-cli.mjs wrapper, its own
// nonce namespace and the human walk, with the exact same-tree webui `cli.mjs serve` as its direct
// parent, read again after the walk as the user kind does. Business and (provider, iss, sub) must be
// the wrapper's initial spawn environment and one of the checked console.sso subjects the runtime
// was started with (Webby N1: every configured subject is an owner). The Console's mapping is trusted
// as row 196 permits; the bus does not exchange an ID token itself.
const SUBJECT_KEYS = ['provider', 'iss', 'sub'];
const SUBJECT_ENV = { business: 'MOSAIC_BUS_CONSOLE_BUSINESS', provider: 'MOSAIC_BUS_CONSOLE_PROVIDER', iss: 'MOSAIC_BUS_CONSOLE_ISS', sub: 'MOSAIC_BUS_CONSOLE_SUB' };
function verifyConsole(proof, { peer, paths, businesses, launches, readProcess, consoleOwners }) {
const refuse = () => { throw new BusError('human-required'); };
if (!proof || typeof proof !== 'object' ||
Object.keys(proof).some(k => !['pid', 'startTime', 'nonce', 'business', ...SUBJECT_KEYS].includes(k)) ||
SUBJECT_KEYS.some(k => typeof proof[k] !== 'string' || !proof[k] || proof[k].length > 512)) refuse();
if (typeof paths.consoleCli !== 'string' || typeof paths.console !== 'string') refuse();
const { provider, iss, sub, ...checked } = proof;
let child, parent;
try {
child = readProcess(proof.pid);
parent = readProcess(child.ppid);
if (parent.uid !== peer.uid || parent.argv[1] !== resolve(paths.console) || parent.argv[2] !== 'serve') refuse();
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (child.env?.[name] !== proof[key]) refuse();
} catch { refuse(); }
const { business } = verifyHuman(checked, { cliPath: paths.consoleCli, launches, readProcess, nonceKey: 'MOSAIC_BUS_CONSOLE_NONCE' });
try {
const again = readProcess(proof.pid), up = readProcess(parent.pid);
if (again.ppid !== parent.pid || again.startTime !== child.startTime || up.startTime !== parent.startTime ||
up.ppid !== parent.ppid || up.uid !== parent.uid || up.argv[1] !== parent.argv[1] || up.argv[2] !== 'serve') refuse();
for (const [key, name] of Object.entries(SUBJECT_ENV)) if (again.env?.[name] !== proof[key]) refuse();
} catch { refuse(); }
if (!Array.isArray(consoleOwners) || !consoleOwners.some(o => o && o.provider === provider && o.iss === iss && o.sub === sub)) refuse();
const id = businesses[business]?.human;
if (!id) throw new BusError('unknown-business');
return { kind: 'console', business, id, via: 'console', subject: { provider, iss, sub } };
}
+14
View File
@@ -102,7 +102,15 @@ export async function startBroker({
// FED R3 (#414): the access file and its loader context, { file, context } (R1's loadAccess).
// Without it no user binds. R6 and R8 pass it; the live boot config doesn't yet.
access = null,
// Row 252 (#1753, design section 2): the checked console.sso subjects, [{provider, iss, sub}], each an
// owner of the business (Webby N1). The boot config carries them; no socket verb sets them. Empty, no
// Console owner binds and the terminal owner is unchanged.
consoleOwners = [],
}) {
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
throw new BusError('invalid-host-request');
const owners = Object.freeze(consoleOwners.map((o) => Object.freeze({ provider: o.provider, iss: o.iss, sub: o.sub })));
if (access !== null && (typeof access?.file !== 'string' || access.context === null || typeof access.context !== 'object'))
throw new BusError('invalid-host-request');
if (librarySync !== null && (typeof librarySync?.command !== 'function'
@@ -388,6 +396,7 @@ export async function startBroker({
tester: fileURLToPath(new URL('./tester-cli.mjs', import.meta.url)),
user: fileURLToPath(new URL('./user-cli.mjs', import.meta.url)),
console: fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url)),
consoleCli: fileURLToPath(new URL('./console-cli.mjs', import.meta.url)),
};
const path = join(store.directory, 'broker.sock');
// The existing host executor owns SSH and its key lifecycle. Combine its revocation
@@ -419,6 +428,11 @@ export async function startBroker({
const p = verifyPrincipal({ user: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords });
return broker.bindUser({ business: p.business, user: p.id });
},
// Row 252 (#1753): a fresh proof per connection, a cap for the Console verbs only.
authenticateConsole: (proof, peer) => {
const p = verifyPrincipal({ console: proof }, { peer, paths: principalPaths, businesses, launches: launchRecords, consoleOwners: owners });
return broker.bindConsole({ business: p.business, human: p.id, subject: p.subject });
},
});
// Row 235 (r6 X3): the remote undo of interrupted host grants, once the bus is up, so a start doesn't
// wait on the network.
+10 -7
View File
@@ -1,6 +1,6 @@
import { createServer } from 'node:net';
import { lstatSync, chmodSync, unlinkSync } from 'node:fs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX } from './broker.mjs';
import { BusError, TASK_VERBS, WEB_VERBS, HOST_VERBS, HOSTS_VERBS, GRANTS_VERBS, PROJECT_FILE_VERBS, LIBRARY_VERBS, PROJECT_SAVE_VERB, PROJECT_SAVE_REQUEST_MAX, CONSOLE_VERBS } from './broker.mjs';
import { inspectInterpreter } from './interpreter.mjs';
import { createPeerReader } from './peercred.mjs';
const LIMIT = 65536;
@@ -13,8 +13,9 @@ const SAVE_VERB_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.
// the web verbs reach it, and without one they refuse web-not-configured (or web-not-granted).
// `host` (row 210) is the trusted host.run executor (host-run.mjs), or null: host.run then refuses
// host-not-configured.
// A request carries exactly one of cap, human (row S4) and tester (row 110) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
// A request carries exactly one of cap, human (row S4), tester (row 110), user (FED R3) and console
// (row 252) proof.
export async function serve({ broker, path, authenticateHuman = null, authenticateTester = null, authenticateUser = null, authenticateConsole = null, peerReader = null, timeout = 5000, tasks = null, web = null, host = null }) {
try {
lstatSync(path);
throw new BusError('socket-exists');
@@ -64,15 +65,17 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
!r ||
Array.isArray(r) ||
typeof r !== 'object' ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'verb', 'args'].includes(k)) ||
Object.keys(r).some((k) => !['cap', 'human', 'tester', 'user', 'console', 'verb', 'args'].includes(k)) ||
typeof r.verb !== 'string' ||
[r.cap, r.human, r.tester, r.user].filter(Boolean).length !== 1
[r.cap, r.human, r.tester, r.user, r.console].filter(Boolean).length !== 1
)
throw new BusError('invalid-envelope');
let cap = r.cap;
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : null;
const kind = r.human ? 'human' : r.tester ? 'tester' : r.user ? 'user' : r.console ? 'console' : null;
// Row 252 (#1753): a Console owner proof reaches its two verbs only, refused before any proof check.
if (kind === 'console' && !CONSOLE_VERBS.includes(r.verb)) throw new BusError('human-required');
if (kind) {
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : authenticateUser;
const authenticate = kind === 'human' ? authenticateHuman : kind === 'tester' ? authenticateTester : kind === 'user' ? authenticateUser : authenticateConsole;
if (!authenticate) throw new BusError(kind === 'tester' ? 'tester-required' : 'human-required');
socket.pause();
let peer;
+2 -8
View File
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
import { createHash } from 'node:crypto';
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
export const SCHEMA_SHA256 = 'd5ca9bb9268994b175ac79d38939be4bb7f2e80bc0d9ea273eedaae50c29b8a9';
export const SCHEMA_VERSION = '3m';
export const SCHEMA_SHA256 = 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a';
export const SCHEMA_VERSION = '3l';
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
@@ -72,11 +72,6 @@ const migrations = [{
from: '3k',
to: '3l',
sql: readFileSync(new URL('./migrations/3k-3l.sql', import.meta.url), 'utf8'),
}, {
// Row 206 (193b2, #1704): thread shares. Only a new table; no existing row changes.
from: '3l',
to: '3m',
sql: readFileSync(new URL('./migrations/3l-3m.sql', import.meta.url), 'utf8'),
}];
const timedTables = [
'events',
@@ -92,7 +87,6 @@ const timedTables = [
'session_defaults',
'thread_claims',
'thread_opens',
'thread_shares',
'instance_names',
'usage_records',
];
@@ -17,8 +17,7 @@ const ddl3h = readFileSync(new URL('./fixtures/schema-3h.sql', import.meta.url),
const here = (p) => new URL(p, import.meta.url);
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3h(t) {
const root = mkdtempSync(join(tmpdir(), 'account-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -85,7 +84,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/3i-3j.sql'), join(moduleRoot, 'src/migrations/3i-3j.sql'));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
@@ -93,7 +91,7 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
const { root, path } = source3h(t), before = snapshot(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
@@ -143,7 +141,7 @@ test('REQ-LIMIT-4 a 3h→3i step that fails partway leaves the store at 3h with
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
// Still a whole 3h store: the real step migrates it.
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(snapshot(path, before.columns).rows), before.rows);
});
@@ -155,14 +153,14 @@ test('REQ-LIMIT-4 the 3h engine refuses a migrated store, migrating or not, and
schema: ddl3h,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3h).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3h';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3h';")
.replace(/\}, \{\n {2}\/\/ Row 126 \(#1620\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3h/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3h';") && !pinned.includes('3h-3i'), 'the 3h engine has no 3h→3i step');
assert.throws(() => new Store3h(root), /schema-mismatch/);
assert.throws(() => new Store3h(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
});
+5 -2
View File
@@ -6,6 +6,9 @@
// bus.mjs for six library-* STATUS entries; no proof or transport change.
// Row 236 (#1742) re-pins human-cli.mjs for the same 130 s wait on the grants verbs; no proof or
// transport change.
// Row 252 (#1753, Webby N5) re-pins human.mjs, whose readProcess also keeps the five
// MOSAIC_BUS_CONSOLE_* keys for the Console wrapper's proof, and webui bus.mjs for consoleCall,
// which shares humanCall's spawn as transport(). The human proof and its spawn are unchanged.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
@@ -15,7 +18,7 @@ import { provedRuntime } from "../../cli/tests/channel-fixture.mjs";
import { Client } from "../src/client.mjs";
const baselines = {
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
"packages/bus/src/human.mjs": "bb13c146717cdc46d75d157177e400edac4d3e10f14fe511027a25fd44cec885",
"packages/bus/src/human-cli.mjs": "271b9781d73cf31cc562a56180e3ebcd725b5fde148a83347a201fdef2fc28af",
"packages/cli/src/transport.mjs": "5129c44b4d9f642e825344df65c9bfb4bac4fc8d6b25343b42d39853dbd40774",
// Row 165 (#1662): seven STATUS entries for the role instance routes (organization-instance-exists,
@@ -25,7 +28,7 @@ const baselines = {
// Row 171 (#1668): project-name-is-repo maps to 409; no proof or transport change.
// Row 194 (#1692, brief item 7, sage-1610 a): organization-tracker-required 400 and
// organization-tracker-bot-taken 409; no proof or transport change.
"packages/webui/src/bus.mjs": "15e8123ffdbb6d067e8a516f5823efdb5de3c869963764f27cd14bb9e4ce3313",
"packages/webui/src/bus.mjs": "701f009e488a261558a22d19f581b052ffd26ce99266f2022dc5d178c12f5272",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+2 -2
View File
@@ -614,7 +614,7 @@ async function database3c(t) {
let source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3c';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3c';")
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.notEqual(pinned, source);
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
@@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
const b = new Broker({ store, businesses: demo(), organization: null });
@@ -33,7 +33,7 @@ test('explicit 3b copy migration preserves evidence and opens as 3g; normal star
const original = readFileSync(source);
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3g to a fixture-only future ver
const sha = createHash('sha256').update(futureDDL).digest('hex');
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3n';")
.replace('}];\nconst timedTables', `}, { from: '3m', to: '3n', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3m';")
.replace('}];\nconst timedTables', `}, { from: '3l', to: '3m', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql', '3l-3m.sql'])
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
const future = new FutureStore(root, { migrateSchema: true });
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3n');
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.match(migrated.stdout, /bus schema 3m ready/);
assert.match(migrated.stdout, /bus schema 3l ready/);
new Store(root).close();
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
assert.equal(usage.status, 2);
+291
View File
@@ -0,0 +1,291 @@
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5, N1-N5): the broker side of the
// Console's Approve and Deny for a grant request. A console cap, made by bindConsole after the
// transport checked the wrapper and the signed-in owner, reaches only decision.preview.console and
// decision.resolve.console, and no other cap reaches those. The answer records via 'console' and a
// human.input that names the owner, written before the grant check (N2), and only that record lets
// an approve start the executor (J13, J14). Q188: no agent caller answers. The grant uses row 234's
// host-enroll raise with a fake keyscan and fake enroll and check steps, so nothing connects anywhere.
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, CONSOLE_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { hostGrant, fingerprint } from '../src/host-grant.mjs';
import { grantDecision } from './host-fixtures.mjs';
const options = [{ key: 'yes', text: 'Yes, enroll it' }, { key: 'no', text: 'No' }];
const SUBJECT = { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const keyLine = (dir, name) => {
execFileSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', 'x', '-f', join(dir, name)]);
return readFileSync(join(dir, name + '.pub'), 'utf8').trim().split(' ').slice(0, 2).join(' ');
};
const refusesSync = (fn, code) => assert.throws(fn, (e) => (assert.equal(e.code, code), true));
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'c252-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const hostKeys = [keyLine(root, 'hk1'), keyLine(root, 'hk2')];
const configDir = join(root, 'config');
const real = hostGrant({ configDir, runRoot: join(configDir, 'secrets', 'run'), scan: async () => hostKeys });
const enrolls = [];
const hg = {
...real,
enroll: async (...args) => (enrolls.push(args), real.enroll(...args)),
check: async (...args) => ({ installerFingerprint: args[2].installer_fingerprint, exit: 0 }),
};
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
pm: { definition: 'pm', authority: { withinRole: ['message.send'], crossRole: [] } },
},
vars: { 'bus.tester': true },
};
const make = () => new Broker({ store, businesses: { demo: business }, hostGrant: hg });
const broker = make();
const caps = {};
for (const role of ['chief', 'pm']) {
caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
broker.request(caps[role], { verb: 'role.claim' });
}
caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
caps.tester = broker.bindTester({ business: 'demo' });
caps.reader = broker.bindReader({ business: 'demo' });
caps.console = broker.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: hostKeys.map(fingerprint) };
const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args });
const raise = (extra = {}) =>
call('chief', 'decision.raise', { action: 'credential.mint', question: 'Enroll web1?', options, recommendation: 'yes', blocking: false, grant, ...extra });
const events = () =>
store
.all("SELECT seq, kind, actor_role, actor_run, subject, body FROM events WHERE business='demo' ORDER BY seq")
// The row's subject is `about`, since a console answer's body has its own `subject`.
.map((e) => ({ seq: e.seq, kind: e.kind, actor_role: e.actor_role, actor_run: e.actor_run, about: e.subject, ...JSON.parse(e.body) }));
const grantEvents = (decision) => events().filter((e) => e.actor_run === 'grant' && (e.decision === decision || e.grantDecision === decision));
const settle = async (decision) => {
for (let i = 0; i < 250; i++) {
if (grantEvents(decision).some((e) => ['grant.host.enrolled', 'grant.failed'].includes(e.action))) return;
await sleep(20);
}
throw new Error('enrollment did not end');
};
const closes = (decision) => store.all('SELECT seq, op, by, choice, via FROM decision_events WHERE decision=? ORDER BY seq', decision);
return { store, broker, make, caps, call, raise, events, grantEvents, settle, closes, enrolls, grant };
}
test('N4: the console verbs are exactly the preview and the console resolve, and decision.resolve is not one', () => {
assert.deepEqual([...CONSOLE_VERBS], ['decision.preview.console', 'decision.resolve.console']);
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
assert.ok(Object.isFrozen(CONSOLE_VERBS));
});
test('bindConsole takes only the business human and a three-string owner subject', (t) => {
const f = setup(t);
const bind = (record) => () => f.broker.bindConsole(record);
refusesSync(bind({ business: 'demo', human: 'someone', subject: SUBJECT }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, name: 'Jason' } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { provider: 'p', iss: 'i' } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: '' } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 'x'.repeat(513) } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: { ...SUBJECT, sub: 7 } }), 'human-required');
refusesSync(bind({ business: 'demo', human: 'jason', subject: 'owner-1' }), 'human-required');
assert.throws(bind({ business: 'demo', human: 'jason', subject: SUBJECT, via: 'cli' }));
assert.throws(bind({ business: 'demo', human: 'jason' }));
assert.throws(bind({ business: 'other', human: 'jason', subject: SUBJECT }));
assert.deepEqual(f.broker.identity(f.caps.console), { business: 'demo', role: null, run: null, human: 'jason' });
});
test('J1, Q188: only a console cap reaches the console verbs, and a console cap reaches nothing else', async (t) => {
const f = setup(t);
const d = await f.raise();
const preview = f.call('console', 'decision.preview.console', { id: d.id });
const args = { id: d.id, choice: 'yes', digest: preview.digest };
// Agents, the cli human, the tester and the reader are refused before the verb runs.
for (const who of ['chief', 'pm', 'human', 'tester', 'reader']) {
refusesSync(() => f.call(who, 'decision.preview.console', { id: d.id }), 'human-required');
refusesSync(() => f.call(who, 'decision.resolve.console', args), 'human-required');
}
// N4: the console cap answers nothing through decision.resolve, and reaches no other verb.
for (const [verb, a] of [
['decision.resolve', { id: d.id, choice: 'yes' }],
['decision.seen', { id: d.id }],
['decision.withdraw', { id: d.id }],
['decision.raise', { action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, grant: f.grant }],
['inbox', {}],
['message.send', { to: 'chief', body: 'hi' }],
['role.claim', {}],
])
refusesSync(() => f.call('console', verb, a), 'human-required');
assert.deepEqual(f.closes(d.id), []);
assert.equal(f.enrolls.length, 0);
});
test('preview: the two real answers, a grant summary and the digest, for an open grant request only', async (t) => {
const f = setup(t);
const d = await f.raise();
const p = f.call('console', 'decision.preview.console', { id: d.id });
assert.equal(p.id, d.id);
assert.equal(p.business, 'demo');
assert.match(p.question, /^Enroll web1\?/);
assert.deepEqual(p.approve, { key: 'yes', text: 'Yes, enroll it' });
assert.deepEqual(p.deny, { key: 'no', text: 'No' });
assert.deepEqual(p.grant, { method: 'host-enroll', host: 'web1' });
assert.match(p.digest, /^[a-f0-9]{64}$/);
// Read twice, the digest is the same; a preview writes nothing.
assert.equal(f.call('console', 'decision.preview.console', { id: d.id }).digest, p.digest);
assert.deepEqual(f.closes(d.id), []);
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id, choice: 'yes' }), 'invalid-request');
refusesSync(() => f.call('console', 'decision.preview.console', { id: 'no-such-decision' }), 'decision-not-found');
// The inbox row says the same, for the human and the reader. The agent-facing views do not.
for (const who of ['human', 'reader']) {
const row = f.call(who, 'inbox').find((r) => r.id === d.id);
assert.deepEqual(row.console, { approve: p.approve, deny: p.deny, grant: p.grant, digest: p.digest });
}
});
test('J1, J2: a decision that is not an eligible grant request refuses both console verbs', async (t) => {
const f = setup(t);
const grant = { method: 'host', agent: 'pm', host: 'web1', commands: ['uptime'], rotateBy: '2099-01-01' };
const ineligible = [
grantDecision(f.store, { action: 'deploy', grant, close: null }),
grantDecision(f.store, { route: 'pm', grant, close: null }),
grantDecision(f.store, { grant: undefined, close: null }),
];
for (const id of ineligible) {
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
assert.equal(f.call('human', 'inbox').find((r) => r.id === id)?.console, undefined);
}
});
test('N2, J13: a Console approve records via console and the owner, before the executor starts, and the grant runs once', async (t) => {
const f = setup(t);
const d = await f.raise();
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest });
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'yes', grant: 'started' });
const [close] = f.closes(d.id);
assert.deepEqual({ ...close, seq: undefined }, { seq: undefined, op: 'resolved', by: 'jason', choice: 'yes', via: 'console' });
const input = f.events().filter((e) => e.kind === 'human.input' && e.about === d.id);
assert.equal(input.length, 1);
assert.deepEqual(
{ ...input[0], seq: undefined },
{ seq: undefined, kind: 'answer', actor_role: null, actor_run: null, about: d.id, decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: close.seq },
);
await f.settle(d.id);
assert.equal(f.enrolls.length, 1);
const spend = f.grantEvents(d.id).find((e) => e.operation === 'grant.host-enroll');
assert.ok(spend && input[0].seq < spend.seq, 'the human.input precedes the executor spend');
assert.ok(f.grantEvents(d.id).some((e) => e.action === 'grant.host.enrolled'));
// N3: the close notice to the raiser comes from the human through the Console.
const notices = f.store.all("SELECT from_run, body FROM messages WHERE business='demo' AND decision=? AND class='DECISION' AND from_role='human'", d.id);
assert.equal(notices.length, 1);
const [notice] = notices;
assert.equal(notice.from_run, 'human-console');
assert.match(notice.body, /resolved by jason: yes \(Yes, enroll it\)/);
// A second answer of either kind finds it closed, and nothing starts again.
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
refusesSync(() => f.call('console', 'decision.preview.console', { id: d.id }), 'decision-closed');
refusesSync(() => f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }), 'decision-closed');
await sleep(50);
assert.equal(f.enrolls.length, 1);
assert.equal(f.closes(d.id).length, 1);
});
test('J15: a Console deny closes with the other key and starts nothing', async (t) => {
const f = setup(t);
const d = await f.raise();
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
const r = f.call('console', 'decision.resolve.console', { id: d.id, choice: 'no', digest });
assert.deepEqual(r, { id: d.id, op: 'resolved', choice: 'no' });
assert.deepEqual(f.closes(d.id).map((c) => [c.op, c.choice, c.via]), [['resolved', 'no', 'console']]);
await sleep(100);
assert.equal(f.enrolls.length, 0);
assert.deepEqual(f.grantEvents(d.id), []);
assert.deepEqual(f.make().reconcileGrants(), []);
});
test('J2: approve and deny come from approvalChoice and the options, not a fixed yes and no', async (t) => {
const f = setup(t);
// Deny listed first, so a build that takes options[0] as approve fails too.
const keyed = { options: [{ key: 'stop', text: 'Stop' }, { key: 'go', text: 'Go ahead' }], approvalChoice: 'go', recommendation: 'go' };
const denied = await f.raise({ ...keyed, question: 'Enroll web1? (deny)' });
const p = f.call('console', 'decision.preview.console', { id: denied.id });
assert.deepEqual([p.approve, p.deny], [{ key: 'go', text: 'Go ahead' }, { key: 'stop', text: 'Stop' }]);
for (const choice of ['yes', 'no']) refusesSync(() => f.call('console', 'decision.resolve.console', { id: denied.id, choice, digest: p.digest }), 'decision-stale');
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: denied.id, choice: 'stop', digest: p.digest }), { id: denied.id, op: 'resolved', choice: 'stop' });
await sleep(100);
assert.equal(f.enrolls.length, 0);
const approved = await f.raise({ ...keyed, question: 'Enroll web1? (approve)' });
const { digest } = f.call('console', 'decision.preview.console', { id: approved.id });
assert.deepEqual(f.call('console', 'decision.resolve.console', { id: approved.id, choice: 'go', digest }), { id: approved.id, op: 'resolved', choice: 'go', grant: 'started' });
await f.settle(approved.id);
assert.equal(f.enrolls.length, 1);
});
test('J11: a stale digest or a key outside the two refuses decision-stale and writes nothing', async (t) => {
const f = setup(t);
const d = await f.raise();
const { digest } = f.call('console', 'decision.preview.console', { id: d.id });
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: 'f'.repeat(64) }), 'decision-stale');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'maybe', digest }), 'decision-stale');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes' }), 'invalid-request');
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest, note: 'x' }), 'invalid-request');
// Another decision's digest does not answer this one.
const other = await f.raise({ question: 'Enroll web1 again?' });
const od = f.call('console', 'decision.preview.console', { id: other.id }).digest;
assert.notEqual(od, digest);
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest: od }), 'decision-stale');
assert.deepEqual(f.closes(d.id), []);
assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0);
// A refusal is recorded, as for every verb.
assert.ok(f.events().some((e) => e.kind === 'action.refused' && e.code === 'decision-stale'));
// The decision closed by the cli meanwhile: the console answer finds it closed.
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
refusesSync(() => f.call('console', 'decision.resolve.console', { id: d.id, choice: 'yes', digest }), 'decision-closed');
});
test('J14: a via console close with no matching human.input approves nothing', (t) => {
const f = setup(t);
const grant = { method: 'host-enroll', host: 'web2', address: '192.0.2.11', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
const cli = grantDecision(f.store, { grant });
const bare = grantDecision(f.store, { grant: { ...grant, host: 'web3' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
const wrong = grantDecision(f.store, { grant: { ...grant, host: 'web4' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
const good = grantDecision(f.store, { grant: { ...grant, host: 'web5' }, close: { op: 'resolved', by: 'jason', choice: 'yes', via: 'console' } });
const seqOf = (id) => f.store.get('SELECT seq FROM decision_events WHERE decision=?', id).seq;
const input = (id, body, role = null) =>
f.store.run(
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',?,?,?,?)",
'e-' + id + '-' + Math.random(), '2026-10-22T00:00:00.000Z', 'demo', role, role && role + '-run', id, JSON.stringify(body),
);
const answer = (id) => ({ kind: 'answer', decision: id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: seqOf(id) });
// `bare` has no human.input at all. `wrong` has records that each break one part: another seq, choice, empty subject, an agent actor.
input(wrong, { ...answer(wrong), decisionSeq: seqOf(wrong) + 1000 });
input(wrong, { ...answer(wrong), choice: 'no' });
input(wrong, { ...answer(wrong), subject: { ...SUBJECT, sub: '' } });
input(wrong, answer(wrong), 'chief');
input(good, answer(good));
// reconcileGrants ends every approved, unspent host-enroll: exactly the cli one and the good console one.
assert.deepEqual(f.make().reconcileGrants().sort(), [cli, good].sort());
assert.equal(f.enrolls.length, 0);
assert.equal(f.store.get("SELECT count(*) AS n FROM events WHERE business='demo' AND actor_run='grant' AND json_extract(body,'$.decision') IN (?,?)", bare, wrong).n, 0);
});
test('recordEvent keeps human.input reserved, so no agent forges a console answer', async (t) => {
const f = setup(t);
const d = await f.raise();
const forged = { kind: 'human.input', subject: d.id, body: { kind: 'answer', decision: d.id, op: 'resolved', choice: 'yes', via: 'console', subject: SUBJECT, decisionSeq: 1 } };
for (const who of ['chief', 'pm']) refusesSync(() => f.broker.recordEvent(f.caps[who], forged), 'reserved-event');
refusesSync(() => f.broker.recordEvent(f.caps.console, forged), 'agent-required');
assert.equal(f.events().filter((e) => e.kind === 'human.input').length, 0);
});
-480
View File
@@ -1,480 +0,0 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
CREATE TABLE events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN (
'session.launched',
'session.ended',
'action.allowed',
'action.refused',
'task.created',
'task.assigned',
'task.state',
'task.closed',
'task.changed.external',
'task.conflict',
'task.missing',
'review.requested',
'review.verdict',
'human.input',
'config.refused',
'credential.expiring',
'credential.expired',
'credential.changed',
'launch.revoked',
'launch.restored',
'digest.sent')),
actor_role TEXT, actor_run TEXT,
subject TEXT,
corrects TEXT REFERENCES events(id),
body TEXT NOT NULL CHECK (json_valid(body))
) STRICT;
CREATE TABLE role_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL, role TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE decisions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL, project TEXT,
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
action TEXT NOT NULL,
route_to TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
recommendation TEXT NOT NULL,
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
supersedes TEXT REFERENCES decisions(id)
, spec_path TEXT, spec_commit TEXT) STRICT;
CREATE TABLE decision_events (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
decision TEXT NOT NULL REFERENCES decisions(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
by TEXT NOT NULL,
choice TEXT, note TEXT, via TEXT
) STRICT;
CREATE TABLE messages (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
to_role TEXT NOT NULL,
class TEXT NOT NULL,
in_reply_to TEXT REFERENCES messages(id),
decision TEXT REFERENCES decisions(id),
corrects TEXT REFERENCES messages(id),
body TEXT NOT NULL
, to_name TEXT) STRICT;
CREATE TABLE deliveries (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL REFERENCES messages(id),
at TEXT NOT NULL,
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
) STRICT;
CREATE TABLE task_snapshots (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
updated TEXT NOT NULL,
etag TEXT,
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
fields TEXT NOT NULL CHECK (json_valid(fields)),
source TEXT NOT NULL CHECK (source IN ('self','poll')),
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
read_at TEXT,
role TEXT, run TEXT,
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
CHECK (json_type(fields, '$.bucket') IS 'integer'
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
) STRICT;
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
WHEN NEW.kind GLOB 'credential.*' AND (
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
OR json_extract(NEW.body, '$.instance') IS NULL)
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.missing' AND (
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
WHEN NEW.kind = 'task.created' AND (
json_type(NEW.body, '$.request') IS NOT 'text'
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
AND kind = 'human.input' AND business = NEW.business)
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
CREATE VIEW launch_state AS
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
CREATE VIEW task_external_changes AS
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
FROM task_snapshots p
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
WHERE p.source = 'poll'
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
CREATE VIEW task_current AS
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
FROM task_snapshots s
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
WHERE c.business = s.business AND c.task_ref = s.task_ref
AND (c.source = 'self'
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
ORDER BY ls.seq DESC LIMIT 1), '')));
CREATE VIEW tasks_open AS
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
FROM task_current s
WHERE json_type(s.fields, '$.gone') IS NULL
AND json_extract(s.fields, '$.done') = 0;
CREATE VIEW urgent_inbox AS
SELECT d.id, d.business, d.task_ref, d.question, d.at
FROM decisions d
WHERE d.class = 'gated' AND d.blocking = 1
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
CREATE TABLE task_comment_cursors (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
business TEXT NOT NULL,
task_id INTEGER NOT NULL CHECK (task_id > 0),
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
) STRICT;
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
CREATE TABLE projects (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
name TEXT NOT NULL,
repo TEXT,
created_by TEXT NOT NULL,
UNIQUE (business, name)
) STRICT;
CREATE TABLE threads (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
project TEXT NOT NULL REFERENCES projects(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
UNIQUE (project, name)
) STRICT;
CREATE TABLE sessions (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
id TEXT NOT NULL UNIQUE,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
instance TEXT NOT NULL,
owner TEXT NOT NULL,
name TEXT NOT NULL,
op TEXT NOT NULL,
predecessor TEXT REFERENCES sessions(id),
UNIQUE (thread, name),
UNIQUE (thread, op)
) STRICT;
CREATE TABLE session_defaults (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE TABLE thread_claims (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
generation INTEGER NOT NULL CHECK (generation >= 1),
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
holder_run TEXT NOT NULL,
harness TEXT NOT NULL, address TEXT,
by TEXT NOT NULL, reason TEXT,
decision TEXT
) STRICT;
CREATE TABLE message_threads (
message TEXT PRIMARY KEY REFERENCES messages(id),
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id)
) STRICT;
CREATE TABLE thread_opens (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
session TEXT NOT NULL REFERENCES sessions(id),
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
CREATE INDEX thread_claims_session ON thread_claims (session, seq);
CREATE INDEX message_threads_thread ON message_threads (thread);
CREATE INDEX message_threads_session ON message_threads (session);
CREATE INDEX session_defaults_thread ON session_defaults (thread, seq);
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
CREATE TRIGGER sessions_thread_match BEFORE INSERT ON sessions
WHEN NOT EXISTS (SELECT 1 FROM threads t WHERE t.id = NEW.thread AND t.business = NEW.business AND t.instance = NEW.instance AND t.owner = NEW.owner)
BEGIN SELECT RAISE(ABORT, 'a session has its thread''s business, instance and owner'); END;
CREATE TRIGGER sessions_predecessor BEFORE INSERT ON sessions
WHEN NEW.predecessor IS NOT NULL AND (SELECT thread FROM sessions WHERE id = NEW.predecessor) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a session''s predecessor is in its thread'); END;
CREATE TRIGGER sessions_id_not_thread BEFORE INSERT ON sessions
WHEN NEW.id IS NOT NEW.thread AND EXISTS (SELECT 1 FROM threads WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a session id names another thread'); END;
CREATE TRIGGER threads_id_not_session BEFORE INSERT ON threads
WHEN EXISTS (SELECT 1 FROM sessions WHERE id = NEW.id)
BEGIN SELECT RAISE(ABORT, 'a thread id names a session'); END;
CREATE TRIGGER session_defaults_match BEFORE INSERT ON session_defaults
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a default session is in its thread'); END;
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) = 'claim'
BEGIN SELECT RAISE(ABORT, 'session already held'); END;
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE session = c.session) AND c.op = 'claim')
BEGIN SELECT RAISE(ABORT, 'run already holds a session'); END;
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1),'') <> 'claim'
BEGIN SELECT RAISE(ABORT, 'session is not held'); END;
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
CREATE TRIGGER thread_claim_generation BEFORE INSERT ON thread_claims
WHEN NEW.generation IS NOT (CASE WHEN NEW.op = 'claim'
THEN 1 + (SELECT count(*) FROM thread_claims WHERE session = NEW.session AND op = 'claim')
ELSE (SELECT generation FROM thread_claims WHERE session = NEW.session ORDER BY seq DESC LIMIT 1) END)
BEGIN SELECT RAISE(ABORT, 'a claim row names its session''s generation'); END;
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
CREATE TRIGGER thread_claims_session BEFORE INSERT ON thread_claims
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a claim''s session is in its thread'); END;
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
CREATE TRIGGER message_threads_session BEFORE INSERT ON message_threads
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'a threaded message''s session is in its thread'); END;
CREATE TRIGGER thread_opens_session BEFORE INSERT ON thread_opens
WHEN (SELECT thread FROM sessions WHERE id = NEW.session) IS NOT NEW.thread
BEGIN SELECT RAISE(ABORT, 'an open''s session is in its thread'); END;
CREATE TRIGGER delivery_same_session BEFORE INSERT ON deliveries
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
WHERE mt.message = NEW.message AND m.to_role = t.instance
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE session = mt.session)
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its session''s holder'); END;
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
CREATE TRIGGER sessions_no_update BEFORE UPDATE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_delete BEFORE DELETE ON sessions BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER sessions_no_replace BEFORE INSERT ON sessions WHEN EXISTS (SELECT 1 FROM sessions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'sessions is append-only'); END;
CREATE TRIGGER session_defaults_no_update BEFORE UPDATE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_delete BEFORE DELETE ON session_defaults BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER session_defaults_no_replace BEFORE INSERT ON session_defaults WHEN EXISTS (SELECT 1 FROM session_defaults WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'session_defaults is append-only'); END;
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
CREATE TABLE instance_names (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
instance TEXT NOT NULL,
name TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE TRIGGER instance_names_unique BEFORE INSERT ON instance_names
WHEN EXISTS (SELECT 1 FROM instance_names n WHERE n.business = NEW.business AND n.name = NEW.name
AND n.instance <> NEW.instance
AND n.seq = (SELECT max(seq) FROM instance_names WHERE business = n.business AND instance = n.instance))
BEGIN SELECT RAISE(ABORT, 'an instance name is unique in its business'); END;
CREATE TRIGGER instance_names_no_update BEFORE UPDATE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_delete BEFORE DELETE ON instance_names BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
CREATE TRIGGER instance_names_no_replace BEFORE INSERT ON instance_names WHEN EXISTS (SELECT 1 FROM instance_names WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'instance_names is append-only'); END;
-- Row 124 (#1618): immutable source snapshots and turn outcomes, never transcript text.
CREATE TABLE usage_records (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
run TEXT NOT NULL,
instance TEXT NOT NULL,
provider TEXT,
model TEXT,
account TEXT,
turn TEXT NOT NULL,
source TEXT NOT NULL,
phase TEXT NOT NULL CHECK (phase IN ('begin','end')),
status TEXT NOT NULL CHECK (status IN ('known','unknown')),
code TEXT,
cursor TEXT CHECK (cursor IS NULL OR json_valid(cursor)),
input INTEGER,
output INTEGER,
cache_write INTEGER,
cache_read INTEGER,
UNIQUE (business,run,turn,source,phase),
CHECK ((status='unknown' AND input IS NULL AND output IS NULL AND cache_write IS NULL AND cache_read IS NULL)
OR (status='known' AND input >= 0 AND output >= 0 AND cache_write >= 0 AND cache_read >= 0
AND input IS NOT NULL AND output IS NOT NULL AND cache_write IS NOT NULL AND cache_read IS NOT NULL))
) STRICT;
CREATE TRIGGER usage_records_no_update BEFORE UPDATE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_delete BEFORE DELETE ON usage_records BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TRIGGER usage_records_no_replace BEFORE INSERT ON usage_records WHEN EXISTS (SELECT 1 FROM usage_records WHERE seq=NEW.seq OR (business=NEW.business AND run=NEW.run AND turn=NEW.turn AND source=NEW.source AND phase=NEW.phase)) BEGIN SELECT RAISE(ABORT, 'usage_records is append-only'); END;
CREATE TABLE hosts (
business TEXT NOT NULL,
host TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL CHECK (port BETWEEN 1 AND 65535),
user TEXT NOT NULL,
sudo INTEGER NOT NULL CHECK (sudo IN (0,1)),
host_keys TEXT NOT NULL CHECK (json_valid(host_keys) AND json_type(host_keys) = 'array' AND json_array_length(host_keys) > 0),
installer_fingerprint TEXT,
state TEXT NOT NULL CHECK (state IN ('pending-install','ready')),
decision TEXT NOT NULL REFERENCES decisions(id),
checked_at TEXT,
PRIMARY KEY (business, host),
CHECK (state = 'pending-install' OR (installer_fingerprint IS NOT NULL AND checked_at IS NOT NULL))
) STRICT;
CREATE TABLE host_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
host TEXT NOT NULL,
commands TEXT NOT NULL CHECK (json_valid(commands) AND json_type(commands) = 'array' AND json_array_length(commands) > 0),
rotate_by TEXT NOT NULL,
tag TEXT NOT NULL CHECK (length(tag) = 39 AND tag GLOB 'mosaic-*' AND NOT substr(tag, 8) GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent, host),
FOREIGN KEY (business, host) REFERENCES hosts(business, host)
) STRICT;
CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
WHEN NEW.kind = 'action.allowed'
AND (json_extract(NEW.body, '$.action') GLOB 'grant.*' OR json_extract(NEW.body, '$.operation') GLOB 'grant.*')
AND NOT (NEW.actor_role IS NULL AND NEW.actor_run IS 'grant')
BEGIN SELECT RAISE(ABORT, 'only the grant writer records grant events'); END;
CREATE TABLE gitea_users (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
id INTEGER NOT NULL CHECK (id > 0),
nonce TEXT NOT NULL CHECK (length(nonce) = 16 AND NOT nonce GLOB '*[^0-9a-f]*'),
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent)
) STRICT;
CREATE TABLE gitea_grants (
business TEXT NOT NULL,
agent TEXT NOT NULL,
user TEXT NOT NULL,
token_id INTEGER NOT NULL CHECK (token_id > 0),
token_name TEXT NOT NULL,
projects TEXT NOT NULL CHECK (json_valid(projects) AND json_type(projects) = 'array' AND json_array_length(projects) > 0),
rotate_by TEXT NOT NULL,
decision TEXT NOT NULL REFERENCES decisions(id),
PRIMARY KEY (business, agent),
FOREIGN KEY (business, agent) REFERENCES gitea_users(business, agent)
) STRICT;
+6 -8
View File
@@ -34,12 +34,11 @@ function source3j(t) {
}
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -75,7 +74,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
writeFileSync(join(moduleRoot, 'src/migrations/3j-3k.sql'), step(readFileSync(here('../src/migrations/3j-3k.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -97,7 +95,7 @@ test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the t
const { root, path } = source3j(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path);
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -123,7 +121,7 @@ test('a 3j→3k step that fails partway leaves the store at 3j with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
});
test('the 3j engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -134,14 +132,14 @@ test('the 3j engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3j,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3j).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3j';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3j';")
.replace(/\}, \{\n {2}\/\/ Row 236 \(#1742[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3j/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3j';") && !pinned.includes('3j-3k'), 'the 3j engine has no 3j→3k step');
assert.throws(() => new Store3j(root), /schema-mismatch/);
assert.throws(() => new Store3j(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+6 -8
View File
@@ -20,8 +20,7 @@ const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hos
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
// Row 264a (#1769): what the 3k→3l step rebuilds or adds. sessions.test.mjs tests those objects.
const SESSIONS_3L = ['sessions', 'session_defaults', 'thread_claims', 'message_threads', 'thread_opens'];
// Row 206 (#1704): and the 3l→3m step's thread_shares. thread-shares.test.mjs tests it.
const later3l = (r) => [...SESSIONS_3L, 'thread_shares'].includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
const later3l = (r) => SESSIONS_3L.includes(r.tbl_name) || ['delivery_same_thread', 'delivery_same_session', 'threads_id_not_session'].includes(r.name);
function source3i(t) {
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
@@ -41,7 +40,7 @@ function source3i(t) {
function rows(path) {
const db = new DatabaseSync(path, { readOnly: true });
try {
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults','thread_shares')").all()
return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('hosts','host_grants','gitea_users','gitea_grants','sessions','session_defaults')").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM ${name} ORDER BY rowid`).all()
.filter(r => name !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key)).map(r => ({ ...r }))]));
} finally { db.close(); }
@@ -78,7 +77,6 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
writeFileSync(join(moduleRoot, 'src/migrations/3i-3j.sql'), step(readFileSync(here('../src/migrations/3i-3j.sql'), 'utf8')));
copyFileSync(here('../src/migrations/3j-3k.sql'), join(moduleRoot, 'src/migrations/3j-3k.sql'));
copyFileSync(here('../src/migrations/3k-3l.sql'), join(moduleRoot, 'src/migrations/3k-3l.sql'));
copyFileSync(here('../src/migrations/3l-3m.sql'), join(moduleRoot, 'src/migrations/3l-3m.sql'));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
function fresh(t) {
@@ -102,7 +100,7 @@ test('3i copy migrates to 3j: every row kept, and sqlite_master gains only the t
const { root, path } = source3i(t), before = rows(path), was = master(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(migrated3l(rows(path)), before);
const now = master(path).filter(r => !LATER.includes(r.name));
assert.deepEqual(now.filter(r => !NEW.includes(r.name) && !later3l(r)), was.filter(r => !later3l(r)));
@@ -127,7 +125,7 @@ test('a 3i→3j step that fails partway leaves the store at 3i with every row an
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
});
test('the 3i engine refuses a migrated store, migrating or not, and leaves it as it was', async t => {
@@ -138,14 +136,14 @@ test('the 3i engine refuses a migrated store, migrating or not, and leaves it as
schema: ddl3i,
store: (source) => source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3i).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3i';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3i';")
.replace(/\}, \{\n {2}\/\/ Row 234 \(#1740\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables'),
});
const pinned = readFileSync(join(root, 'engine-3i/src/store.mjs'), 'utf8');
assert.ok(pinned.includes("export const SCHEMA_VERSION = '3i';") && !pinned.includes('3i-3j'), 'the 3i engine has no 3i→3j step');
assert.throws(() => new Store3i(root), /schema-mismatch/);
assert.throws(() => new Store3i(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), after);
});
+1 -1
View File
@@ -65,7 +65,7 @@ test('3f copy migrates to 3g preserving every old column/row, with an empty inst
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM instance_names').n, 0);
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: 'docs/spec.md', spec_commit: 'c'.repeat(40) });
+49
View File
@@ -46,3 +46,52 @@ test('human leaf identity is reread after walking the parents',()=>{
c.readProcess=pid=>{const p=read(pid);if(pid===50&&n++>0)p.startTime='101';return p;};
assert.throws(()=>verifyPrincipal({human:proof},c),/human-required/);
});
// Row 252 (#1753, design section 2, J3-J5): the Console owner. The same-tree console-cli wrapper with
// its own nonce, a direct same-tree `serve` parent, the subject from the wrapper's spawn environment,
// and that subject one of the configured owners (N1).
const owner={provider:'fake-oidc',iss:'https://idp.test',sub:'owner-1'};
function consoleContext() {
const c=context('user');c.paths={...paths,consoleCli:'/tree/console-cli.mjs'};c.consoleOwners=[{provider:'other',iss:'https://idp.test',sub:'owner-2'},owner];
Object.assign(c.table.get(50),{argv:['node','/tree/console-cli.mjs'],env:{MOSAIC_BUS_CONSOLE_NONCE:nonce,MOSAIC_BUS_CONSOLE_BUSINESS:'demo',MOSAIC_BUS_CONSOLE_PROVIDER:owner.provider,MOSAIC_BUS_CONSOLE_ISS:owner.iss,MOSAIC_BUS_CONSOLE_SUB:owner.sub}});
return c;
}
const consoleProof={...proof,...owner};
test('console requires its own wrapper, nonce and serve parent, and a configured owner from the spawn env',()=>{
assert.deepEqual(verifyPrincipal({console:consoleProof},consoleContext()),{kind:'console',business:'demo',id:'owner',via:'console',subject:owner});
// J3: the subject is the spawn's, never the body's alone, and must be a configured owner.
for(const change of [{sub:'owner-2'},{iss:'https://evil.test'},{provider:'other'}])
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...change}},consoleContext()),/human-required/);
const unlisted=consoleContext();unlisted.consoleOwners=[];
assert.throws(()=>verifyPrincipal({console:consoleProof},unlisted),/human-required/);
for(const extra of [{name:'Jason'},{human:'owner'},{user:'owner'},{sub:''},{sub:'x'.repeat(513)}])
assert.throws(()=>verifyPrincipal({console:{...consoleProof,...extra}},consoleContext()),/human-required/);
// J4: the parent is the same-tree serve, direct, and not an agent.
for(const change of [{argv:['bash']},{argv:['node','/other/console.mjs','serve']},{argv:['node',paths.console,'other']},{env:{CODEX_THREAD_ID:'agent'}},{uid:uid+1}]) {
const c=consoleContext();Object.assign(c.table.get(40),change);
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
const launched=consoleContext();launched.launches=[{pid:40,startTime:'90'}];
assert.throws(()=>verifyPrincipal({console:consoleProof},launched),/human-required/);
// J5: another nonce namespace, or the wrapper path of another kind, refuses.
for(const env of [{MOSAIC_BUS_CLI_NONCE:nonce},{MOSAIC_BUS_USER_NONCE:nonce}]) {
const c=consoleContext();const t=c.table.get(50);t.env={...t.env,...env};delete t.env.MOSAIC_BUS_CONSOLE_NONCE;
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
for(const argv of [['node',paths.human],['node',paths.user],['node','/other/console-cli.mjs']]) {
const c=consoleContext();c.table.get(50).argv=argv;
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
// The Console's own nonce does not answer as the cli human or the user.
assert.throws(()=>verifyPrincipal({human:proof},consoleContext()),/human-required/);
assert.throws(()=>verifyPrincipal({user:{...proof,user:'owner'}},consoleContext()),/human-required/);
// Each piece is read again after the walk: a parent or a spawn subject that changed refuses.
for(const [pid,mutate] of [[40,p=>{p.startTime='91';}],[50,p=>{p.env={...p.env,MOSAIC_BUS_CONSOLE_SUB:'owner-2'};}],[50,p=>{p.ppid=41;}]]) {
const c=consoleContext(),read=c.readProcess;let n=0;
// The last read of each pid is verifyConsole's own reread: 40 is read 3 times, 50 is read 4 times.
c.readProcess=x=>{const p=read(x);if(x===pid&&n++===(pid===40?2:3))mutate(p);return p;};
assert.throws(()=>verifyPrincipal({console:consoleProof},c),/human-required/);
}
for(const peer of [{pid:51,uid,gid:uid},{pid:50,uid:uid+1,gid:uid}])assert.throws(()=>verifyPrincipal({console:consoleProof},{...consoleContext(),peer}),/human-required/);
assert.throws(()=>verifyPrincipal({console:consoleProof,human:proof},consoleContext()),/invalid-envelope/);
});
+2 -3
View File
@@ -522,8 +522,7 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
// Row 264a (#1769) adds sessions, session_defaults and 15 triggers; sessions.test.mjs tests them.
// Row 206 (#1704) adds thread_shares and its 3 append-only triggers; thread-shares.test.mjs tests them.
assert.equal(count('table') - 1, 23);
assert.equal(count('trigger'), 89);
assert.equal(count('table') - 1, 22);
assert.equal(count('trigger'), 86);
assert.equal(count('view'), 5);
});
+3 -3
View File
@@ -374,7 +374,7 @@ test('T7: a 3k store migrates to 3l, each thread\'s default session has the thre
const { root, path } = source3k(t);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'sessions-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
@@ -430,7 +430,7 @@ async function engine3k(root) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3k).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3k';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3k';")
.replace(/\}, \{\n {2}\/\/ Row 264a \(#1769\)[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3k';") && edited.includes('3j-3k') && !edited.includes('3k-3l'), 'the 3k engine has no 3k→3l step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
@@ -447,7 +447,7 @@ test('T7 D9: the 3k engine refuses a 3l store, migrating or not, and leaves it a
const Store3k = await engine3k(root);
assert.throws(() => new Store3k(root), /schema-mismatch/);
assert.throws(() => new Store3k(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.equal(version(path), '3l');
assert.deepEqual(master(path), was);
assert.deepEqual(rows(path), before);
// The same engine opens a fresh 3k store, so the refusal is the version's and not a broken copy.
+1 -1
View File
@@ -63,7 +63,7 @@ test('3e copy migrates through 3f to 3g preserving every old column/row and addi
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
+3 -3
View File
@@ -49,7 +49,7 @@ async function database3d(t) {
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const pinned = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3d';")
.replace("export const SCHEMA_VERSION = '3l';", "export const SCHEMA_VERSION = '3d';")
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
@@ -101,7 +101,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
t.after(() => store.close());
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
@@ -161,5 +161,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
assert.equal(migrated.status, 0, migrated.stderr);
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3m ready', ''].join('\n'));
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3l ready', ''].join('\n'));
});
-439
View File
@@ -1,439 +0,0 @@
// Row 206 (193b2, #1704, row 193 design 5.2-5.6): a thread owner's read-only shares. The owner shares
// a thread with a user, the business human or everyone; #readable lets a sharee read it while the
// share is in force, the sharee's grant still covers it and a user owner's grant does too. Every
// write verb keeps the owner check. Mutants 2, 3, 4, 7, 8 and 9 of design section 8 die here.
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash, randomUUID } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
const shipped = (name) =>
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const here = (p) => new URL(p, import.meta.url);
const grant = (over = {}) => ({
id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
});
// As user-principal.test.mjs: the access document is a function each test edits between requests.
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-thread-shares-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [] }, fail: false };
const b = new Broker({
store,
businesses: {
demo: {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
},
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
},
},
access: () => {
if (access.fail) throw new Error('access file refused');
return access.doc;
},
});
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
reader: b.bindReader({ business: 'demo' }),
tester: b.bindTester({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (who, verb, args = {}) => {
try {
call(who, verb, args);
} catch (e) {
return e.code;
}
return null;
};
const p1 = call('human', 'project.create', { name: 'shared' }).id;
const p2 = call('human', 'project.create', { name: 'private' }).id;
// alice and bob hold collaborator grants on p1, carol on p2 only.
const base = () => [grant({ projects: [p1] }), grant({ id: 'g-2', user: 'bob', projects: [p1] }), grant({ id: 'g-3', user: 'carol', projects: [p2] })];
access.doc.grants = base();
for (const name of ['alice', 'bob', 'carol']) caps[name] = b.bindUser({ business: 'demo', user: name });
const A = call('alice', 'thread.create', { project: p1, name: 'alice work' }).id;
const sent = call('alice', 'thread.send', { thread: A, body: 'alice: hello' }).id;
const H = call('human', 'thread.create', { project: p1, name: 'owner work' }).id;
call('human', 'thread.send', { thread: H, body: 'jason: hello' });
const rows = () => store.get('SELECT count(*) n FROM thread_shares').n;
const read = (who, thread) => call(who, 'thread.messages', { thread }).map((m) => m.body);
return { b, store, access, caps, call, code, p1, p2, base, A, sent, H, rows, read };
}
test('a share lets the sharee read the thread and nothing else: every write verb stays the owner\'s (mutants 7, 9)', (t) => {
const { call, code, A, p1, rows, read } = setup(t);
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
const [listed] = call('bob', 'thread.list', { shared: true });
assert.deepEqual(Object.keys(listed), ['id', 'at', 'project', 'instance', 'name', 'owner']);
assert.deepEqual({ ...listed, at: null }, { id: A, at: null, project: p1, instance: 'assistant', name: 'alice work', owner: 'user:alice' });
// The plain list keeps its shape and holds only bob's own threads.
assert.deepEqual(call('bob', 'thread.list'), []);
assert.deepEqual(Object.keys(call('alice', 'thread.list')[0]), ['id', 'at', 'project', 'instance', 'name']);
// Mutant 7 (#threadRead for thread.send) and mutant 9 (no owner check on thread.share) die here.
for (const [verb, args] of [
['thread.send', { body: 'bob was here' }],
['thread.open', {}],
['thread.share', { to: '*' }],
['thread.share', { to: 'jason' }],
['thread.unshare', { to: 'user:bob' }],
['thread.shares', {}],
]) assert.equal(code('bob', verb, { thread: A, ...args }), 'thread-not-owned', verb);
assert.equal(rows(), 1);
assert.deepEqual(read('alice', A), ['alice: hello']);
// The share is bob's alone: the owner and carol still can't read.
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('human', 'thread.list', { shared: true }), []);
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
});
test('the business human shares too, and a `*` share reaches every covered principal but the tester', (t) => {
const { call, code, A, H, rows, read } = setup(t);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: 'user:alice' }), { thread: H, to: 'user:alice', op: 'share' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.equal(code('bob', 'thread.messages', { thread: H }), 'thread-not-owned');
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: '*' }), { thread: A, to: '*', op: 'share' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(read('human', A), ['alice: hello']);
assert.deepEqual(call('human', 'thread.list', { shared: true }).map((x) => x.id), [A]);
// Rule 2: carol's grant doesn't cover p1, so `*` doesn't reach her. Rule 4: nor the tester.
assert.equal(code('carol', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('carol', 'thread.list', { shared: true }), []);
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('tester', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => [x.to, x.by]), [['*', 'user:alice']]);
assert.deepEqual(call('human', 'thread.shares', { thread: H }).map((x) => [x.to, x.by]), [['user:alice', 'jason']]);
assert.equal(rows(), 2);
});
test('mutant 2: a share never widens a grant; when the sharee\'s grant stops covering the thread, its reads end', (t) => {
const { access, call, code, base, A, p2, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
// bob keeps a collaborator grant, so the user gate passes, but it now covers p2 only.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, projects: [p2] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
// A grant on another instance is the same.
access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, instances: ['chief'] } : g));
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
access.doc.grants = base();
assert.deepEqual(read('bob', A), ['alice: hello']);
// Removed, expired or an unreadable file: the gate refuses bob's next request.
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'bob'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'bob' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('bob', 'thread.messages', { thread: A }), 'access-revoked', what);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('bob', A), ['alice: hello'], what);
}
});
test('mutant 3: a thread whose user owner has lost coverage is hidden from its sharees, the owner\'s board included', (t) => {
const { access, call, code, base, A, sent, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
assert.deepEqual(read('human', A), ['alice: hello']);
assert.ok(call('human', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
assert.ok(call('reader', 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent));
for (const [what, change] of [
['removed', () => { access.doc.grants = base().filter((g) => g.user !== 'alice'); }],
['expired', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, expires: '2000-01-01T00:00:00Z' } : g)); }],
['out of scope', () => { access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: ['none'] } : g)); }],
['unreadable', () => { access.fail = true; }],
]) {
change();
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned', what);
assert.deepEqual(call('human', 'thread.list', { shared: true }), [], what);
for (const who of ['human', 'reader']) assert.deepEqual(call(who, 'trail', { subject: sent }), [], `${who} ${what}`);
access.doc.grants = base();
access.fail = false;
assert.deepEqual(read('human', A), ['alice: hello'], what);
}
// A viewer grant still covers: rule 3 asks for any level.
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g));
assert.deepEqual(read('human', A), ['alice: hello']);
});
test('mutant 4: a share\'s state is its latest row, so an unshare ends it and a share after it starts it again', (t) => {
const { store, call, code, A, rows, read } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.equal(code('alice', 'thread.share', { thread: A, to: 'user:bob' }), 'share-exists');
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), { thread: A, to: 'user:bob', op: 'unshare' });
assert.equal(code('bob', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.deepEqual(call('bob', 'thread.list', { shared: true }), []);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }), []);
assert.equal(code('alice', 'thread.unshare', { thread: A, to: 'user:bob' }), 'share-not-found');
assert.equal(code('alice', 'thread.unshare', { thread: A, to: '*' }), 'share-not-found');
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.deepEqual(call('alice', 'thread.shares', { thread: A }).map((x) => x.to), ['user:bob']);
assert.deepEqual(store.all('SELECT op,target,by FROM thread_shares ORDER BY seq').map((r) => ({ ...r })), [
{ op: 'share', target: 'user:bob', by: 'user:alice' },
{ op: 'unshare', target: 'user:bob', by: 'user:alice' },
{ op: 'share', target: 'user:bob', by: 'user:alice' },
]);
// An unshare of `*` leaves bob's own share in force.
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.unshare', { thread: A, to: '*' });
assert.deepEqual(read('bob', A), ['alice: hello']);
assert.equal(code('human', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(rows(), 5);
});
test('mutant 8: a user target needs a live grant of any level covering the thread, else share-not-covered', (t) => {
const { access, call, code, base, A, p1, rows } = setup(t);
// carol's grant covers p2 only; dave has none; erin's is expired.
access.doc.grants = [...base(), grant({ id: 'g-4', user: 'erin', projects: [p1], expires: '2000-01-01T00:00:00Z' })];
for (const to of ['user:carol', 'user:dave', 'user:erin'])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'share-not-covered', to);
assert.equal(rows(), 0);
// A viewer grant is enough to be shared with, though at this base it can't bind a session.
access.doc.grants = [...base(), grant({ id: 'g-5', user: 'vic', projects: [p1], level: 'viewer' })];
assert.deepEqual(call('alice', 'thread.share', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'share' });
// An unshare checks no coverage: an uncovered target's share can still be ended.
access.doc.grants = base();
assert.deepEqual(call('alice', 'thread.unshare', { thread: A, to: 'user:vic' }), { thread: A, to: 'user:vic', op: 'unshare' });
assert.equal(rows(), 2);
});
test('a share target is `user:<id>`, the business human or `*`, and never the sharer', (t) => {
const { call, code, A, H, rows } = setup(t);
for (const to of ['user:alice', 'bob', 'tester', 'user:tester', 'user:human', 'user:user', 'user:jason', 'user:', 'user:Bad Name', '', 42, null, ['*']])
assert.equal(code('alice', 'thread.share', { thread: A, to }), 'invalid-request', JSON.stringify(to));
for (const to of ['jason', 'user:x y'])
assert.equal(code('human', 'thread.share', { thread: H, to }), 'invalid-request', to);
assert.equal(code('alice', 'thread.share', { thread: A }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: A, to: '*', extra: 1 }), 'invalid-request');
assert.equal(code('alice', 'thread.shares', { thread: A, to: '*' }), 'invalid-request');
assert.equal(code('alice', 'thread.share', { thread: randomUUID(), to: '*' }), 'thread-not-found');
assert.equal(code('alice', 'thread.list', { shared: false }), 'invalid-request');
assert.equal(code('alice', 'thread.list', { shared: 'yes' }), 'invalid-request');
assert.equal(rows(), 0);
// The owner shares its own threads only; a user's thread refuses the business human too.
assert.equal(code('human', 'thread.share', { thread: A, to: '*' }), 'thread-not-owned');
assert.equal(code('alice', 'thread.share', { thread: H, to: '*' }), 'thread-not-owned');
assert.equal(rows(), 0);
assert.deepEqual(call('human', 'thread.share', { thread: H, to: '*' }).op, 'share');
});
test('a user whose collaborator grant no longer covers its own thread can\'t share, unshare or list its shares', (t) => {
const { access, call, code, base, A, p2, rows } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'user:bob' });
access.doc.grants = base().map((g) => (g.user === 'alice' ? { ...g, projects: [p2] } : g));
for (const [verb, to] of [['thread.share', '*'], ['thread.unshare', 'user:bob'], ['thread.shares', undefined]])
assert.equal(code('alice', verb, { thread: A, ...(to ? { to } : {}) }), 'access-refused', verb);
assert.equal(rows(), 1);
});
test('the tester and a reader session never share: each verb refuses and writes nothing', (t) => {
const { store, call, code, A, H, rows } = setup(t);
const refusals = () => store.get("SELECT count(*) n FROM events WHERE kind='action.refused'").n;
const T = call('tester', 'thread.create', { project: call('human', 'project.list')[0].id, name: 'tester work' }).id;
const was = refusals();
for (const verb of ['thread.share', 'thread.unshare', 'thread.shares'])
for (const thread of [T, H, A]) {
assert.equal(code('tester', verb, { thread, to: '*' }), 'tester-refused', `${verb} ${thread}`);
assert.equal(code('reader', verb, { thread, to: '*' }), 'read-only', `${verb} ${thread}`);
}
// Each refusal is recorded, as every refused request is.
assert.equal(refusals() - was, 18);
assert.equal(rows(), 0);
// A share with `*` of the owner's thread doesn't reach the tester's reads.
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
test('the owner\'s trail and replies show a user\'s message only while that user\'s thread is shared with the owner', (t) => {
const { store, call, code, A, sent } = setup(t);
// The run's reply to alice, as R3's `to: 'user'` path writes it.
const reply = randomUUID();
store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
reply, new Date().toISOString(), 'demo', 'assistant', 'assistant-run', 'user', 'INFO', sent, null, null, 'hello alice',
);
store.run('INSERT INTO message_threads(message,thread,session) VALUES(?,?,?)', reply, A, A);
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
call('alice', 'thread.share', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.ok(call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent), who);
assert.deepEqual(call(who, 'replies', { id: sent }).map((m) => m.id), [reply], who);
}
// The tester never reads another's thread (rule 4).
assert.deepEqual(call('tester', 'trail', { subject: sent }), []);
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('alice', 'thread.unshare', { thread: A, to: 'jason' });
for (const who of ['human', 'reader']) {
assert.deepEqual(call(who, 'trail', { subject: sent }), [], who);
assert.equal(code(who, 'replies', { id: sent }), 'message-not-found', who);
}
});
test('a share covers every session of the thread', (t) => {
const { store, call, code, H, read } = setup(t);
call('human', 'session.create', { thread: H, name: 'second', op: 'op-second' });
const S = store.get("SELECT id FROM sessions WHERE thread=? AND name='second'", H).id;
call('human', 'thread.send', { thread: H, session: S, body: 'jason: second' });
call('human', 'thread.share', { thread: H, to: 'user:alice' });
assert.deepEqual(read('alice', H), ['jason: hello']);
assert.deepEqual(call('alice', 'thread.messages', { thread: H, session: S }).map((m) => m.body), ['jason: second']);
assert.equal(code('alice', 'thread.messages', { thread: H, session: randomUUID() }), 'session-not-found');
assert.equal(code('bob', 'thread.messages', { thread: H, session: S }), 'thread-not-owned');
});
test('a sharee can\'t list the thread\'s sessions: session.list stays owner-only (Q7, sage-2818 Q1)', (t) => {
const { call, code, A, H } = setup(t);
call('alice', 'thread.share', { thread: A, to: 'jason' });
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('human', 'session.list', { thread: A }), 'thread-not-owned');
assert.equal(code('reader', 'session.list', { thread: A }), 'read-only');
for (const who of ['alice', 'bob']) assert.equal(code(who, 'session.list', { thread: H }), 'access-refused', who);
// The owners still list their own.
assert.equal(call('human', 'session.list', { thread: H }).length, 1);
});
test('only a human principal matches a share: an agent bound to the thread and the tester match none (sage-2818 Q2)', (t) => {
const { b, caps, call, code, A, H, sent } = setup(t);
// The assistant run serving alice's thread, and a chief run with no thread.
caps.agent = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'assistant-run', harness: 'pi', thread: A });
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
for (const who of ['agent', 'chief']) call(who, 'role.claim');
const seen = (who) => call(who, 'trail', { subject: sent }).some((r) => r.table === 'messages' && r.id === sent);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} before`);
call('alice', 'thread.share', { thread: A, to: '*' });
call('alice', 'thread.share', { thread: A, to: 'jason' });
// The human principals now read it; the agents and the tester see what they saw before.
for (const who of ['human', 'reader']) assert.equal(seen(who), true, who);
for (const who of ['agent', 'chief', 'tester']) assert.equal(seen(who), false, `${who} after`);
for (const who of ['agent', 'chief']) {
assert.equal(code(who, 'thread.messages', { thread: A }), 'human-required', who);
assert.equal(code(who, 'replies', { id: sent }), 'human-required', who);
}
assert.equal(code('tester', 'thread.messages', { thread: A }), 'thread-not-owned');
assert.equal(code('tester', 'replies', { id: sent }), 'message-not-found');
call('human', 'thread.share', { thread: H, to: '*' });
assert.equal(code('tester', 'thread.messages', { thread: H }), 'thread-not-owned');
});
// The 3l→3m step. A 3l store from fixtures/schema-3l.sql, with one row in each thread table.
const AT = '2026-10-22T00:00:00.000Z';
const ddl3l = readFileSync(here('./fixtures/schema-3l.sql'), 'utf8');
function source3l(t) {
const root = mkdtempSync(join(tmpdir(), 'thread-shares-migrate-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
mkdirSync(join(root, 'bus'), { mode: 0o700 });
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
assert.equal(createHash('sha256').update(ddl3l).digest('hex'), 'cd68512212ce8bf8f5631138b2e63a340fa7b940088430649bb7784af7df162a');
db.exec(ddl3l);
const run = (sql, ...args) => db.prepare(sql).run(...args);
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3l');
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'p-1', AT, 'demo', 'stack work', 'stack', 'jason');
for (const [id, owner] of [['t1', 'jason'], ['t2', 'user:alice']]) {
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', id, AT, 'demo', 'p-1', 'assistant', owner, id);
run("INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) VALUES(?,?,?,?,?,?,'default','legacy-3l')", AT, 'demo', id, id, 'assistant', owner);
run("INSERT INTO session_defaults(at,thread,session,by) VALUES(?,?,?,'migration-3l')", AT, id, id);
}
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'm-1', AT, 'demo', 'user', 'user:alice', 'assistant', 'INFO', 'm-1');
run('INSERT INTO message_threads VALUES(?,?,?)', 'm-1', 't2', 't2');
run('INSERT INTO thread_opens(seq,at,thread,session,by) VALUES(?,?,?,?,?)', 7, AT, 't2', 't2', 'user:alice');
db.close();
chmodSync(path, 0o600);
return { root, path };
}
const open = (path, fn) => {
const db = new DatabaseSync(path, { readOnly: true });
try { return fn(db); } finally { db.close(); }
};
const master = (path) => open(path, (db) => db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map((r) => ({ ...r })));
// Every table's rows but meta's, whose version and digest the step changes.
const tables = (path) => open(path, (db) => Object.fromEntries(
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('meta') ORDER BY name").all()
.map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all().map((r) => ({ ...r }))]),
));
const version = (path) => open(path, (db) => db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value);
test('a 3l store migrates to 3m: thread_shares is new and empty, and every other row stays as it was', (t) => {
const { root, path } = source3l(t);
const before = tables(path);
assert.throws(() => new Store(root), /schema-mismatch/);
new Store(root, { migrateSchema: true }).close();
assert.equal(version(path), '3m');
const after = tables(path);
assert.deepEqual(after.thread_shares, []);
delete after.thread_shares;
assert.deepEqual(after, before);
// The migrated schema is the fresh one, object for object.
const freshRoot = mkdtempSync(join(tmpdir(), 'thread-shares-fresh-'));
t.after(() => rmSync(freshRoot, { recursive: true, force: true }));
new Store(freshRoot).close();
assert.deepEqual(master(path), master(join(freshRoot, 'bus/bus.sqlite')));
// No existing thread is shared: each stays private to its owner.
const store = new Store(root);
t.after(() => store.close());
store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'share', 'jason', 'user:alice');
for (const [sql, args] of [
["UPDATE thread_shares SET target='*'", []],
['DELETE FROM thread_shares', []],
['INSERT OR REPLACE INTO thread_shares(seq,at,business,thread,op,target,by) VALUES(?,?,?,?,?,?,?)', [1, AT, 'demo', 't2', 'unshare', 'jason', 'user:alice']],
]) assert.throws(() => store.run(sql, ...args), /thread_shares is append-only/, sql);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 't2', 'grant', '*', 'user:alice'), /CHECK/);
assert.throws(() => store.run('INSERT INTO thread_shares(at,business,thread,op,target,by) VALUES(?,?,?,?,?,?)', AT, 'demo', 'no-such', 'share', '*', 'jason'), /FOREIGN KEY/);
assert.deepEqual(store.all('SELECT seq,thread,op,target FROM thread_shares').map((r) => ({ ...r })), [{ seq: 1, thread: 't2', op: 'share', target: 'jason' }]);
});
// The 3l engine is store.mjs at this change with the 3l schema and version and no 3l→3m step.
async function engine3l(root) {
const moduleRoot = join(root, 'engine-3l');
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
const source = readFileSync(here('../src/store.mjs'), 'utf8');
const edited = source
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3l).digest('hex')}';`)
.replace("export const SCHEMA_VERSION = '3m';", "export const SCHEMA_VERSION = '3l';")
.replace(/\}, \{\n {2}\/\/ Row 206 \(193b2[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
assert.ok(edited.includes("export const SCHEMA_VERSION = '3l';") && edited.includes('3k-3l') && !edited.includes('3l-3m'), 'the 3l engine has no 3l→3m step');
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
writeFileSync(join(moduleRoot, 'schema.sql'), ddl3l);
for (const file of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql', '3f-3g.sql', '3g-3h.sql', '3h-3i.sql', '3i-3j.sql', '3j-3k.sql', '3k-3l.sql'])
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
}
test('the 3l engine refuses a 3m store, migrating or not, and leaves it as it was', async (t) => {
const { root, path } = source3l(t);
new Store(root, { migrateSchema: true }).close();
const was = master(path), before = tables(path);
const Store3l = await engine3l(root);
assert.throws(() => new Store3l(root), /schema-mismatch/);
assert.throws(() => new Store3l(root, { migrateSchema: true }), /schema-mismatch/);
assert.equal(version(path), '3m');
assert.deepEqual(master(path), was);
assert.deepEqual(tables(path), before);
// The same engine opens a fresh 3l store, so the refusal is the version's and not a broken copy.
const { root: old } = source3l(t);
new Store3l(old).close();
});
+1 -1
View File
@@ -68,7 +68,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h, 3i, 3j and 3k to 3l preserving ev
assert.throws(() => new Store(root), /schema-mismatch/);
const store = new Store(root, { migrateSchema: true });
try {
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3m');
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
assert.deepEqual({ ...store.get('SELECT to_name FROM messages') }, { to_name: null });
assert.equal(store.get('SELECT count(*) AS n FROM usage_records').n, 0);
assert.equal(store.get('SELECT name FROM instance_names').name, 'Builder');
+1 -3
View File
@@ -139,9 +139,7 @@ test('R3: every verb off the collaborator list refuses a user access-refused bef
]);
// R3 r6 (sage-2576): the collaborator list as ruled, written out, so a widened USER_VERBS fails here
// rather than leaving the set below with the verb it added (M3x, M6x).
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names',
// Row 206 (193b2): the share verbs, owner only (thread-shares.test.mjs).
'thread.share', 'thread.unshare', 'thread.shares']);
assert.deepEqual(USER_VERBS, ['project.list', 'thread.create', 'thread.list', 'thread.open', 'thread.send', 'thread.messages', 'names']);
for (const v of USER_VERBS) others.delete(v);
assert.ok(others.size > 40);
for (const verb of others) {
+6
View File
@@ -83,6 +83,9 @@ function trackerFor(system, b, warn) {
};
}
export const consoleOwners = (sso) =>
sso.providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, iss: p.issuer, sub: s.sub })));
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
// business. The host holds a reader capability for the notifier and binds
// launches later through bindLaunch, so `launches` starts empty.
@@ -101,6 +104,9 @@ export function bootConfig({ system, businessId, env = process.env, warn = () =>
};
const tracker = trackerFor(vars, b, warn);
if (tracker) config.trackers = { [b.id]: tracker };
// Row 252 (#1753, Webby N1): every console.sso subject is an owner, by provider id, issuer and sub.
// The loader checked console.sso; no new config field names owners.
if (system.console?.sso) config.consoleOwners = consoleOwners(system.console.sso);
return config;
});
}
+16
View File
@@ -69,3 +69,19 @@ test("an unknown business and a broken system config refuse with exit 3", (t) =>
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: "nope", env: f.env }), (e) => e.exitCode === 3);
assert.throws(() => loadSystem({ env: { ...f.env, MOSAIC_CONFIG: join(f.dataRoot, "missing.json") } }), (e) => e.exitCode === 3);
});
test("Row 252 (Webby N1): every console.sso subject reaches the boot as a console owner, and no sso means no consoleOwners key", (t) => {
const f = fixture(tmp(t));
const system = loadSystem({ env: f.env });
assert.equal("consoleOwners" in bootConfig({ system, businessId: "acme", env: f.env }), false);
const sso = { providers: [
{ id: "authentik", issuer: "https://auth.example/app/", subjects: [{ sub: "jason-sub" }, { sub: "second-sub" }] },
{ id: "other", issuer: "https://idp.example", subjects: [{ sub: "jason-sub" }] },
] };
const boot = bootConfig({ system: { ...system, console: { sso } }, businessId: "acme", env: f.env });
assert.deepEqual(boot.consoleOwners, [
{ provider: "authentik", iss: "https://auth.example/app/", sub: "jason-sub" },
{ provider: "authentik", iss: "https://auth.example/app/", sub: "second-sub" },
{ provider: "other", iss: "https://idp.example", sub: "jason-sub" },
]);
});
+1 -1
View File
@@ -858,7 +858,7 @@ const COUNTED=/^COUNTED="([^"]*)"$/m.exec(srText)[1].split(' ');
const STORE_JS=/^STORE_JS='([\s\S]*?)'$/m.exec(srText)[1];
// sage-1163's list 2, written out so that a table dropped from the script's COUNTED fails T45 as
// well as T46.
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','thread_shares','instance_names','usage_records'];
const LISTED=['role_claims','thread_claims','decision_events','messages','deliveries','task_snapshots','projects','threads','sessions','session_defaults','thread_opens','instance_names','usage_records'];
const LATE='2999-01-01T00:00:00.000Z';
const addRow=(file,table,at=LATE)=>{const db=new DatabaseSync(file);db.exec(`CREATE TABLE IF NOT EXISTS ${table} (seq INTEGER PRIMARY KEY, at TEXT NOT NULL)`);db.prepare(`INSERT INTO ${table} (at) VALUES (?)`).run(at);db.close()};
+16 -1
View File
@@ -335,13 +335,28 @@ note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
same functions, so both show the same broker data.
- Nothing writes but the "Message a role" box, the Settings update, the Assistant
section's three buttons and the spec and docs editor's Save, all under `--send` (above,
"Assistant settings" and "Spec and docs editor"). A decision shows `mosaic decide <id> <key>` for each choice,
"Assistant settings" and "Spec and docs editor"), and the decision Approve and Deny below.
A decision shows `mosaic decide <id> <key>` for each choice,
with Copy, which only puts the command on the clipboard. It is answered in a
terminal (REQ-DEC-3, Q4). Seen is not set from the browser either. The
command has no `--business`, so `mosaic decide` uses the running bus host's
business, as the Console does by default. From a Console started with
`--business` for another business the copied command refuses (no bus host,
or no such decision) rather than answering elsewhere.
- Row 252 (#1753, PRD 1.9 REQ-WEB-2): a grant request (`credential.mint` routed
to the human, raised with a grant and two options) can also be approved or
denied on its decision page, only when the Console runs with both `--sso` and
`--send` and the page is signed in as a `console.sso` subject. The first click
POSTs `/api/decisions/<id>/confirmation` and shows what the answer does; only
Confirm POSTs `/resolve` with that one-use confirmation (two minutes, bound to
the session, decision and choice). Each step spawns
`packages/bus/src/console-cli.mjs`, which the bus verifies as the Console's
child holding the signed-in subject, and the bus records the answer `via
console` with a `human.input` naming that subject. Nothing retries; an unknown
outcome says to refresh. Without `--sso` the page has no buttons and says why;
under `--sso` without `--send` it says the Console is read-only for decisions.
The tester listener has no decision routes. Every other decision stays
terminal-only.
- 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,
+34 -11
View File
@@ -10,9 +10,11 @@
import { spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { HUMAN_CLI } from '../../cli/src/transport.mjs';
import { readHostState } from '../../cli/src/host.mjs';
import { views } from '../../bus/src/views.mjs';
import { CONSOLE_VERBS } from '../../bus/src/broker.mjs';
export class BusReadError extends Error {
constructor(code, message = code) { super(message); this.code = code; }
@@ -26,10 +28,13 @@ export const subjectOk = s => typeof s === 'string' && s.length > 0 && s.length
// request on stdin, JSON on stdout, or a bus error code as the last
// `^[a-z-]{1,64}$` line of stderr.
export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000, maxBytes = 8 * 1024 * 1024 }) {
return (verb, args = {}) => new Promise((resolve, reject) => {
return (verb, args = {}) => transport({ cli, socket, env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, input: { business, verb, args }, timeoutMs, maxBytes });
}
function transport({ cli, socket, env, input, timeoutMs, maxBytes }) {
return new Promise((resolve, reject) => {
let child;
try {
child = spawn(process.execPath, [cli, socket], { env: cli === HUMAN_CLI ? { ...env, MOSAIC_BUS_CLI_NONCE: randomBytes(32).toString('hex') } : env, stdio: ['pipe', 'pipe', 'pipe'] });
child = spawn(process.execPath, [cli, socket], { env, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { return reject(new BusReadError('outcome-unknown', 'the bus transport did not start')); }
const out = [], err = [];
let size = 0, settled = false;
@@ -52,24 +57,42 @@ export function humanCall({ socket, business, env = process.env, cli = HUMAN_CLI
catch { done(reject, new BusReadError('invalid-response')); }
});
child.stdin.on('error', () => {}); // A child that exits early reports through 'close'.
child.stdin.end(`${JSON.stringify({ business, verb, args })}\n`);
child.stdin.end(`${JSON.stringify(input)}\n`);
});
}
// Row 252 (#1753, design section 2): the Console owner's answer to a grant request. Each call spawns
// a fresh console-cli with a fresh nonce, and the business and the signed-in (provider, iss, sub) go
// in its spawn environment, where the bus reads them back. Inherited console keys are dropped first,
// so only this call's values reach the wrapper. Only the two console verbs pass (N4). A resolve pins
// the business its confirmation was issued for.
export const CONSOLE_CLI = fileURLToPath(new URL('../../bus/src/console-cli.mjs', import.meta.url));
export function consoleCall({ dataRoot, socket, business = null, env = process.env, cli = CONSOLE_CLI, timeoutMs = 30000 }) {
return async (verb, args, { subject, business: pinned = null }) => {
if (!CONSOLE_VERBS.includes(verb)) throw new Error(`the Console does not call ${verb}`);
const id = pinned ?? (business || hostBusiness(dataRoot));
const own = Object.fromEntries(Object.entries(env).filter(([k]) => !k.startsWith('MOSAIC_BUS_CONSOLE_')));
return transport({
cli, socket, timeoutMs, maxBytes: 1024 * 1024, input: { verb, args },
env: { ...own, MOSAIC_BUS_CONSOLE_NONCE: randomBytes(32).toString('hex'), MOSAIC_BUS_CONSOLE_BUSINESS: id,
MOSAIC_BUS_CONSOLE_PROVIDER: subject.provider, MOSAIC_BUS_CONSOLE_ISS: subject.iss, MOSAIC_BUS_CONSOLE_SUB: subject.sub },
});
};
}
// The business is the --business flag, or else the live bus host's, read
// on every request so a host started after the Console is picked up.
export function busCaller({ dataRoot, socket, business = null, env = process.env, cli = HUMAN_CLI, timeoutMs = 30000 }) {
return async (verb, args) => {
let id = business;
if (!id) {
let state;
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
id = state.business;
}
return humanCall({ socket, business: id, env, cli, timeoutMs })(verb, args);
return humanCall({ socket, business: business || hostBusiness(dataRoot), env, cli, timeoutMs })(verb, args);
};
}
function hostBusiness(dataRoot) {
let state;
try { state = readHostState(dataRoot); } catch { throw new BusReadError('no-bus-host', 'the bus host state file is unreadable'); }
if (!state?.live) throw new BusReadError('no-bus-host', 'no bus host is running and no --business was given');
return state.business;
}
export const busReader = options => views({ call: busCaller(options) });
// A send's error, sorted. The broker writes in one transaction, so any code it
+7 -4
View File
@@ -2,7 +2,7 @@
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
import { createChat } from './chat.mjs';
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
import { busCaller } from './bus.mjs';
import { busCaller, consoleCall } from './bus.mjs';
import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
@@ -72,7 +72,7 @@ try {
// the system config, Business can't resolve authority and Settings has
// no notifier. A refusal printed here names no path (redactor).
// --send without a bus refuses to start rather than serve a composer that can't send.
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, busLine, system = null;
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, decisionWrite = null, busLine, system = null;
let processing = null;
try {
system = loadSystem();
@@ -91,6 +91,9 @@ try {
projectFile = args => call('project.file', args);
libraryFile = args => call('library.file', args); libraryStatus = args => call('library.status', args);
if (sendOn) {send = args => call('message.send', args);librarySync=args=>call('library.sync',args);settingsWrite=args=>call('limits.set',args);projectSave=args=>call('project.file.save',args);nameWrite=args=>call('name.set',args);assistantStop=args=>call('assistant.stop',args);instancesWrite=args=>call('instances.set',args);routesWrite=args=>call('routes.set',args);agentWrite=agentCaller(call,AGENT_VERBS);}
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
// --sso and --send.
if (ssoOn && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
@@ -110,7 +113,7 @@ try {
}
let server;
try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing });
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing, decisionWrite });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
@@ -134,7 +137,7 @@ try {
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
const health = sso?.health() ?? {};
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
+148
View File
@@ -0,0 +1,148 @@
// Row 252 (#1753, Gander's design approved by Webby at 5d8f0f4b5): Approve and Deny for a grant
// request, from the signed-in Console only. Two POSTs. /confirmation asks the bus for the decision's
// two answers and digest and returns a one-use confirmation bound to this session, business,
// decision, choice and digest. /resolve takes only that choice and confirmation, consumes the
// confirmation before it awaits the bus, and asks the bus to resolve with the digest. The bus
// rechecks everything in the resolve transaction. Nothing here retries.
import { createHash, randomBytes } from 'node:crypto';
import { BusReadError, subjectOk } from './bus.mjs';
export const DECISION_ROUTE = /^\/api\/decisions\/([^/]*)\/(confirmation|resolve)$/;
// The endpoint contract, design section 4. Each refusal is one of these, never a 200.
export const DECISION_REFUSALS = Object.freeze({
'sign-in-required': [401, 'Sign in again before answering.'],
'read-only': [403, 'Console decision answers are not enabled. Use mosaic decide.'],
'cross-origin': [403, 'Answer from the signed-in Console page.'],
'not-found': [404, 'Not found.'],
'human-required': [403, 'The bus refused this human identity.'],
'peercred-required': [403, 'The bus could not verify this connection.'],
'method-not-allowed': [405, 'Use POST.'],
'invalid-request': [400, 'Invalid decision answer.'],
'request-too-large': [413, 'Decision answer is too large.'],
'decision-not-found': [404, 'This decision is unavailable.'],
'decision-not-eligible': [403, 'Answer this decision with mosaic decide.'],
'decision-closed': [409, 'This decision is no longer open. Refresh the inbox.'],
'decision-stale': [409, 'The choices changed. Review the decision again.'],
'confirmation-required': [409, 'Review and confirm this answer again.'],
'confirmation-unavailable': [503, 'Confirmation is unavailable. Try again later.'],
'outcome-unknown': [503, 'The answer may have reached the bus. Refresh before trying again.'],
});
// Bus codes by name. A bus that refuses this caller is human-required, whatever its code. Any
// other code, or no answer, is confirmation-unavailable at the preview and outcome-unknown at the
// resolve, which the page reads as "refresh first".
const BUS_CODES = {
'human-required': 'human-required', unauthenticated: 'human-required', 'unknown-business': 'human-required',
'access-refused': 'human-required', 'access-revoked': 'human-required', 'agent-required': 'human-required',
'peercred-required': 'peercred-required',
'decision-not-found': 'decision-not-found', 'decision-not-eligible': 'decision-not-eligible',
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
'invalid-request': 'invalid-request',
};
const BODY_MAX = 4096;
const TOKEN = /^[a-f0-9]{64}$/;
const DIGEST = /^[a-f0-9]{64}$/;
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
const hash = (token) => createHash('sha256').update(token).digest('hex');
export function decisionRefuse(res, code) {
const [status, message] = DECISION_REFUSALS[code];
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify({ error: code, message }));
}
// The confirmations, in this process's memory only and stored by hash. 120 seconds, at most 1024
// live. A full store refuses a new one rather than drop a live one. An expired one is swept.
export function createConfirmations({ ttlMs = 120000, max = 1024, now = Date.now } = {}) {
const live = new Map();
const sweep = () => { const t = now(); for (const [k, v] of live) if (v.expires <= t) live.delete(k); };
return {
issue(binding) {
sweep();
if (live.size >= max) return null;
const token = randomBytes(32).toString('hex');
live.set(hash(token), { ...binding, expires: now() + ttlMs });
return token;
},
// One use: a token found is gone, whether or not it matches.
consume(token, { session, decision, choice }) {
sweep();
if (typeof token !== 'string' || !TOKEN.test(token)) return null;
const k = hash(token), v = live.get(k);
if (!v) return null;
live.delete(k);
return v.session === session && v.decision === decision && v.choice === choice ? v : null;
},
get size() { sweep(); return live.size; },
};
}
async function readBody(req) {
if (one(req, 'content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') return { code: 'invalid-request' };
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > BODY_MAX) return { code: 'request-too-large' };
chunks.push(chunk);
}
try {
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? { value } : { code: 'invalid-request' };
} catch { return { code: 'invalid-request' }; }
}
const preview = (v, id) =>
v && typeof v === 'object' && v.id === id && typeof v.business === 'string' && DIGEST.test(v.digest ?? '') &&
['approve', 'deny'].every((k) => typeof v[k]?.key === 'string' && typeof v[k]?.text === 'string') &&
v.approve.key !== v.deny.key && v.grant && typeof v.grant.method === 'string';
// `session` is the live sign-in session object this request passed, and `live()` reads it again
// right before each spawn. `call(verb, args, { subject, business })` is the console transport.
export async function decisionHTTP(req, res, { path, decide, session, live, confirmations, log }) {
const [, raw, step] = DECISION_ROUTE.exec(path);
let id = null;
try { id = decodeURIComponent(raw); } catch { /* refused below */ }
if (req.method !== 'POST') return decisionRefuse(res, 'method-not-allowed');
if (!decide) return decisionRefuse(res, 'read-only');
if (!session) return decisionRefuse(res, 'sign-in-required');
if (one(req, 'origin') !== decide.origin || one(req, 'sec-fetch-site') !== 'same-origin') return decisionRefuse(res, 'cross-origin');
const line = (outcome) => log(`${new Date().toISOString()} decide user=${JSON.stringify(String(session.name ?? '').slice(0, 64))} decision=${JSON.stringify(String(id ?? '').slice(0, 64))} step=${step} ${outcome}`);
const refuse = (code) => { line(`error=${code}`); return decisionRefuse(res, code); };
const { value, code } = await readBody(req);
if (code) return refuse(code);
if (!subjectOk(id)) return refuse('decision-not-found');
const subject = { provider: session.provider, iss: session.iss, sub: session.sub };
const busCode = (err, open) => {
const c = err instanceof BusReadError ? BUS_CODES[err.code] : undefined;
return c ?? open;
};
if (step === 'confirmation') {
if (Object.keys(value).length !== 1 || typeof value.choice !== 'string') return refuse('invalid-request');
if (live() !== session) return refuse('sign-in-required');
let v;
try { v = await decide.call('decision.preview.console', { id }, { subject }); } catch (err) { return refuse(busCode(err, 'confirmation-unavailable')); }
if (!preview(v, id)) return refuse('confirmation-unavailable');
const action = value.choice === v.approve.key ? 'approve' : value.choice === v.deny.key ? 'deny' : null;
if (!action) return refuse('decision-stale');
if (live() !== session) return refuse('sign-in-required');
const confirmation = confirmations.issue({ session, business: v.business, decision: id, choice: value.choice, digest: v.digest });
if (!confirmation) return refuse('confirmation-unavailable');
line(`action=${action}`);
return json(res, 200, { id, question: typeof v.question === 'string' ? v.question : '', action, choice: value.choice, text: v[action].text, grant: v.grant, confirmation });
}
const keys = Object.keys(value);
if (keys.length !== 2 || !keys.includes('choice') || !keys.includes('confirmation') || typeof value.choice !== 'string') return refuse('invalid-request');
// Consumed before any await on the bus, so a second click finds nothing (J12).
const held = confirmations.consume(value.confirmation, { session, decision: id, choice: value.choice });
if (!held) return refuse('confirmation-required');
if (live() !== session) return refuse('sign-in-required');
let r;
try {
r = await decide.call('decision.resolve.console', { id, choice: held.choice, digest: held.digest }, { subject, business: held.business });
} catch (err) { return refuse(busCode(err, 'outcome-unknown')); }
if (r?.id !== id || r.op !== 'resolved' || r.choice !== held.choice) return refuse('outcome-unknown');
line(`choice=${JSON.stringify(held.choice.slice(0, 64))}${r.grant ? ` grant=${r.grant}` : ''}`);
return json(res, 200, { id, op: 'resolved', choice: r.choice, ...(r.grant === 'started' ? { grant: 'started' } : {}) });
}
function json(res, status, body) {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify(body));
}
+123 -9
View File
@@ -190,8 +190,9 @@
out.kind = 'decision.raised'; out.decision = r.id; out.actor = { role: r.raised_by_role, run: r.raised_by_run };
out.text = `Raised a ${r.class} decision for ${r.route_to}: ${String(r.question).split('\n')[0]}`;
} else if (r.table === 'decision_events') {
// The human answers from the CLI (via cli); every other row is the broker's.
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : 'broker';
// The human answers from the CLI (via cli) or, since row 252 (#1753), a grant request from the
// signed-in Console (via console); every other row is the broker's.
out.kind = `decision.${r.op}`; out.decision = r.decision; out.by = r.via === 'cli' ? 'human' : r.via === 'console' ? 'human-console' : 'broker';
out.text = r.op === 'resolved' ? `${r.by} chose “${r.choice}” via ${r.via}` : r.op === 'seen' ? `${r.by} saw it${r.via ? ` via ${r.via}` : ''}` : `${r.op[0].toUpperCase()}${r.op.slice(1)} by ${r.by}${r.note ? `: ${r.note}` : ''}`;
} else if (r.table === 'task_snapshots') {
out.kind = `snapshot.${r.source}`;
@@ -227,8 +228,9 @@
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI, outside any run) or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : 'broker'}</span>`;
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
// or from the broker itself.
const actor = (a, by) => `<span class="source">${a ? `${txt(a.role)} · ${txt(a.run)}` : by === 'human' ? 'human, CLI outside any run' : by === 'human-console' ? 'human, signed-in Console' : 'broker'}</span>`;
const PRIORITY = ['unset', 'low', 'medium', 'high', 'urgent', 'do now'];
const head = (title, sub) => `<div class="page-head"><div><h1 tabindex="-1">${title}</h1>${sub ? `<p class="small muted" id="bus-status">${sub}</p>` : ''}</div></div>`;
let readAt = null;
@@ -256,7 +258,7 @@
// Inbox
function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}</p>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge('Approve or Deny here', 'ok')}` : ''}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
@@ -314,12 +316,101 @@
el.outerHTML = composerHtml();
restore(k);
}
// Row 252 (#1753, design section 5): Approve and Deny for an open grant request, drawn only when
// the server says this Console answers decisions (`decide` in /api/config: signed in through SSO
// and started with --send) and the bus marked the inbox row eligible (its `console` field). The
// first click asks the Console for a one-use confirmation that names what the answer does. Only
// the second click answers. Nothing here retries: a refusal drops the confirmation, and an
// unknown outcome says to refresh first. The state lives in `answer`, not the DOM, so the 10 s
// refresh redraws it. The server and the bus check everything again; this is presentation only.
let canDecide = false, ssoOn = false, answer = null;
const ACTIONS = { approve: 'Approve', deny: 'Deny' };
const grantText = g => [`method ${g.method}`, g.agent && `agent ${g.agent}`, g.host && `host ${g.host}`, Array.isArray(g.projects) && g.projects.length && `projects ${g.projects.join(', ')}`].filter(Boolean).map(s => txt(String(s))).join(' · ');
function decideHtml() {
const a = answer;
if (!a) return '';
const off = a.pending ? ' disabled' : '';
let body;
if (a.result) {
const r = a.result;
body = `<p class="receipt" id="decide-status" tabindex="-1">${r.action === 'approve' ? 'Approved' : 'Denied'}: you chose “${txt(r.choice)}”, ${txt(r.text)}. ${r.grant === 'started' ? 'The grant executor started.' : r.action === 'approve' ? 'The bus started no grant for it.' : 'Nothing starts for a denial.'} History below shows it once the bus is read again.</p>`;
} else if (a.confirm) {
const c = a.confirm, label = `Confirm ${c.action}`;
body = `<div class="s1-confirm" role="group" aria-labelledby="b-confirm"><h3 id="b-confirm" class="small">${label}</h3>
<dl class="kv"><dt>Id</dt><dd><code>${txt(c.id)}</code></dd><dt>Question</dt><dd class="s1-q">${txt(c.question)}</dd><dt>Answer</dt><dd><b class="s1-key">${txt(c.choice)}</b> ${txt(c.text)}</dd><dt>Grant</dt><dd>${grantText(c.grant)}</dd></dl>
<p class="small muted">${c.action === 'approve' ? 'Approving starts this grant.' : 'Denying starts nothing.'} This confirmation works once and lasts two minutes.</p>
<div class="s1-cmd"><button type="button" class="btn primary" data-decide="confirm"${off}>${a.pending ? 'Answering…' : label}</button><button type="button" class="btn" data-decide="cancel"${off}>Cancel</button></div></div>`;
} else {
body = `<ul class="s1-plain">${Object.entries(ACTIONS).map(([k, l]) => `<li><b>${l}</b> chooses <b class="s1-key">${txt(a.console[k].key)}</b> ${txt(a.console[k].text)}</li>`).join('')}</ul>
<p class="small muted">Grant: ${grantText(a.console.grant)}. Either button opens a confirmation first. Nothing is answered until you confirm.</p>
<div class="s1-cmd">${Object.entries(ACTIONS).map(([k, l]) => `<button type="button" class="btn${k === 'approve' ? ' primary' : ''}" data-decide="${k}"${off}>${a.pending === k ? 'Checking…' : l}</button>`).join('')}</div>`;
}
const e = a.error;
const said = e ? `<p class="receipt failed" id="decide-status" tabindex="-1">Not answered here: ${txt(e.message)} (<code>${txt(e.code)}</code>).${e.code === 'outcome-unknown' ? ' Nothing was sent again.' : ''}</p>` : '';
return `<section class="panel s1-decide" id="decide-section" aria-labelledby="b-decide"><h2 id="b-decide">Answer in Console</h2>${body}${said}</section>`;
}
// Redraw only the answer panel. `focus` names what takes focus after it.
function paintDecide(focus) {
const el = $('decide-section');
if (!el) return;
el.outerHTML = decideHtml();
(focus ? $('decide-section')?.querySelector(focus) : null)?.focus();
}
async function decidePost(url, body) {
let res = null, json = null;
try {
res = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });
try { json = await res.json(); } catch { json = null; }
} catch { res = null; }
return { res, json };
}
// A refusal the Console wrote carries its code and message (design section 4). Anything else, a
// 408 or no answer included, gets the fallback: at the resolve step that is outcome-unknown.
const DECIDE_FALLBACK = {
'confirmation-unavailable': 'Confirmation is unavailable. Try again later.',
'outcome-unknown': 'The answer may have reached the bus. Refresh before trying again.',
};
const decideRefusal = (res, json, fallback) => res && !res.ok && res.status !== 408 && typeof json?.error === 'string' && typeof json?.message === 'string'
? { code: json.error, message: json.message } : { code: fallback, message: DECIDE_FALLBACK[fallback] };
async function decide(step) {
const a = answer;
if (!a || a.pending || a.result) return;
if (step === 'cancel') { a.confirm = null; a.error = null; paintDecide('[data-decide="approve"]'); announce('Cancelled. Nothing was answered.'); return; }
if (out()) { a.confirm = null; a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); return; }
if (step === 'approve' || step === 'deny') {
const choice = a.console[step].key;
a.pending = step; a.error = null; a.confirm = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/confirmation`, { choice });
a.pending = null;
// The page moved to another decision meanwhile: its confirmation is dropped, never shown.
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.action === step && json.choice === choice && typeof json.confirmation === 'string' && json.grant && typeof json.grant.method === 'string') {
a.confirm = json; paintDecide('[data-decide="confirm"]'); announce(`Confirm ${step}: ${json.text}`);
} else { a.error = decideRefusal(res, json, 'confirmation-unavailable'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
return;
}
if (step !== 'confirm' || !a.confirm) return;
const c = a.confirm;
a.pending = 'confirm'; a.error = null; paintDecide();
const { res, json } = await decidePost(`/api/decisions/${enc(a.id)}/resolve`, { choice: c.choice, confirmation: c.confirmation });
// One use, whatever came back.
a.pending = null; a.confirm = null;
if (answer !== a) { if (res?.type === 'opaqueredirect') signOut(); else render(false); return; }
if (res?.type === 'opaqueredirect') { a.error = { code: 'sign-in-required', message: 'Sign in again before answering. The sign-in proxy redirected the answer, so it never reached the Console.' }; paintDecide('#decide-status'); signOut(); return; }
if (res?.ok && json?.id === a.id && json.op === 'resolved' && json.choice === c.choice) {
a.result = { action: c.action, choice: c.choice, text: c.text, grant: json.grant ?? null };
paintDecide('#decide-status'); announce(c.action === 'approve' ? 'Approved' : 'Denied'); render(false);
} else { a.error = decideRefusal(res, json, 'outcome-unknown'); paintDecide('#decide-status'); announce(`Not answered: ${a.error.message}`); }
}
async function inboxView(get) {
// The broker orders gated first, then oldest; lift blocking ones to the top.
const rows = (await get('inbox')).slice().sort((a, b) => (b.blocking - a.blocking) || String(a.at).localeCompare(String(b.at)));
countInbox(rows);
const body = rows.length ? `<ul class="waiting s1-decs">${rows.map(decisionCard).join('')}</ul>` : empty('Nothing is waiting on you.', 'A decision shows here when an agent raises one routed to you through the bus.');
return `${head('Inbox', `${readNote()} Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.`)}
const answered = canDecide ? 'A grant request can be approved or denied here, after a confirmation. Every other decision is answered in a terminal with <code>mosaic decide</code>.'
: 'Decisions are answered in a terminal with <code>mosaic decide</code>; Console only shows them.';
return `${head('Inbox', `${readNote()} ${answered}`)}
${await composer(get)}
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
@@ -343,6 +434,13 @@
// A command is offered only for an open decision routed to the human, and only when
// the id and key pass the broker's identifier rule, so the copied text is safe to paste.
const answerable = !c && d.routeTo === 'human';
// Row 252: the answer panel follows this decision. A refresh keeps a confirmation open; a
// decision no longer eligible (closed elsewhere, say) drops it, unless an answer is in flight
// or this page just answered it.
const eligible = canDecide && answerable && open?.console ? open.console : null;
if (answer?.id !== d.id) answer = eligible ? { id: d.id, console: eligible, pending: null, confirm: null, error: null, result: null } : null;
else if (eligible && !answer.pending && !answer.confirm) answer.console = eligible;
else if (!eligible && !answer.pending && !answer.result) answer = null;
const options = `<ol class="s1-opts">${d.options.map(o => {
const cmd = `mosaic decide ${d.id} ${o.key}`, chosen = c?.choice === o.key, approving = d.authorization?.approvalChoice === o.key;
const copy = !answerable ? '' : SAFE.test(String(d.id)) && SAFE.test(String(o.key))
@@ -352,11 +450,18 @@
}).join('')}</ol>`;
const earlier = await chain(get, d);
const history = [...d.seen.map(s => `<li>Seen by ${txt(s.by)}${s.via ? ` via ${txt(s.via)}` : ''} ${when(s.at)}</li>`), ...(c ? [`<li>${esc(c.op)} by ${txt(c.by)}${c.via ? ` via ${txt(c.via)}` : ''} ${when(c.at)}</li>`] : [])];
const help = answerable ? '<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.</p>'
// Without SSO, or under SSO without --send, the Console says why it has no buttons and never
// draws a disabled one (design section 5).
const why = !answerable || isTester || canDecide ? ''
: ssoOn ? ' This Console is read-only for decisions: it was started without writes enabled (--send), so answers go through a terminal.'
: ' Answering a grant request from the Console needs sign-in (--sso) and writes enabled (--send).';
const help = eligible ? '<p class="small muted">Approve or Deny in the panel above, or run one of these in a terminal. Copy only puts the command on your clipboard.</p>'
: answerable ? `<p class="small muted">To answer, run one of these in a terminal. Copy only puts the command on your clipboard. Console sends nothing.${why}</p>`
: !c ? `<p class="small muted">This decision is routed to ${txt(d.routeTo)}, not to you, so Console offers no command for it.</p>` : '';
return `${head(`Decision ${txt(short(d.id))}`, status)}
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
${answer?.id === d.id ? decideHtml() : ''}
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}<p class="small muted">${gap('A later version that replaces this one')}</p></section>
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
@@ -1098,6 +1203,7 @@
return {
href: view.contains(a) ? a.getAttribute('href') : null, copy: view.contains(a) ? a.dataset?.copy : null, retry: view.contains(a) && a.hasAttribute?.('data-retry'), mirror: view.contains(a) ? a.dataset?.mirror : null, h1: view.contains(a) && a.tagName === 'H1',
field: view.contains(a) ? a.dataset?.field : null, caret: view.contains(a) && a.tagName === 'TEXTAREA' ? [a.selectionStart, a.selectionEnd, a.scrollTop] : null, send: view.contains(a) && a.hasAttribute?.('data-send'),
decide: view.contains(a) ? a.dataset?.decide ?? (a.id === 'decide-status' ? 'status' : null) : null,
open: [...view.querySelectorAll('details[id]')].filter(d => d.open).map(d => d.id), status: $('copy-status')?.textContent || '',
};
}
@@ -1105,7 +1211,10 @@
for (const id of k.open) { const d = $(id); if (d) d.open = true; }
if ($('copy-status')) $('copy-status').textContent = k.status;
const el = [...$('bus-view').querySelectorAll('a[href],[data-copy],[data-retry],[data-mirror],[data-field],[data-send]')].find(e => k.href ? e.getAttribute('href') === k.href : k.copy ? e.dataset.copy === k.copy : k.mirror ? e.dataset.mirror === k.mirror : k.field ? e.dataset.field === k.field : k.send ? e.hasAttribute('data-send') : k.retry && e.hasAttribute('data-retry'));
if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
// Row 252: the answer panel's buttons and its status line.
const dec = k.decide ? $('bus-view').querySelector(k.decide === 'status' ? '#decide-status' : `[data-decide="${k.decide}"]`) : null;
if (dec) dec.focus({ preventScroll: true });
else if (el && (k.href || k.copy || k.retry || k.mirror || k.field || k.send)) {
el.focus({ preventScroll: true });
if (k.caret && el.tagName === 'TEXTAREA') { el.setSelectionRange(k.caret[0], k.caret[1]); el.scrollTop = k.caret[2]; }
}
@@ -1197,6 +1306,9 @@
}
return;
}
// Row 252: Approve, Deny, Confirm and Cancel. One click is one step; none repeats itself.
const dec = e.target.closest('#decide-section [data-decide]');
if (dec) { if (!dec.disabled) decide(dec.dataset.decide); return; }
if (e.target.closest('#toast [data-dismiss]')) { toast(null); return; }
const item = e.target.closest('#cmdk-list [data-href]');
if (item) { pkGo(item.dataset.href); return; }
@@ -1318,12 +1430,14 @@
isTester = c?.tester === true;
canEdit = !isTester && c?.edit === true;
canSend = !isTester && c?.send === true;
ssoOn = c?.sso === true;
canDecide = !isTester && c?.decide === true;
if (isTester) {
const banner = document.createElement('div'); banner.id = 'tester-banner'; banner.className = 'banner ref';
banner.setAttribute('role', 'status'); banner.textContent = 'Tester Console. Changes are recorded as tester, never as the human.';
$('main').prepend(banner);
}
if (!canSend && !isTester) return;
if (!canSend && !isTester && !ssoOn) return;
const path = routeOf().split('?')[0];
if (path.startsWith('/chat')||path.startsWith('/inbox')||path==='/settings'||path.startsWith('/docs/')) render(false); else if (path === '/' || path === '') paintHeld(heldErr);
}).catch(() => {});
+19 -5
View File
@@ -10,6 +10,7 @@ const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403
import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { chatHTTP } from './chat-http.mjs';
import { processingHTTP } from './processing-http.mjs';
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -579,7 +580,7 @@ async function projectFileRead(res, read, path, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, processing = null, decisionWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
@@ -590,6 +591,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
const confirmations = createConfirmations();
const upstream = boardURL(board);
// An object with none of the three is refused too: asking for the preview never serves without it.
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
@@ -641,18 +645,21 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
let signer = preview;
const decisionPath = DECISION_ROUTE.test(path);
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`))
return decisionPath ? decisionRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
let signer = preview, ssoSession = null;
if (sso !== null && named) {
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
if (!session && path !== '/healthz') {
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
}
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
if (session) { signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin }; ssoSession = session; }
}
if (named && preview !== null) {
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
@@ -660,6 +667,13 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
// session on the public name under --sso and --send, they are read-only.
if (decisionPath) {
if (tester) return decisionRefuse(res, 'not-found');
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: sso.publicOrigin } : null;
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => sso.session(req), confirmations, log });
}
// Row 194 (#1692, D4.1): the tester also launches and terminates.
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
@@ -763,7 +777,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, decide: !tester && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
@@ -0,0 +1,231 @@
// Row 252 (#1753, design section 5, J16 and J17): Approve and Deny on a decision page, in a browser.
// A loopback page has no sign-in, so a script added before the page loads sets `sso` and `decide`
// in /api/config from the page URL, and answers each POST /api/decisions/... from a queue, recording
// what the page sent. Server-side checks are decision-http.test.mjs's and the bus side is
// packages/bus/tests/console-decide.test.mjs's. The decisions come from an in-process broker
// through its reader session. Refresh timers fire after 150 ms.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { grantDecision } from '../../bus/tests/host-fixtures.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2">';
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'pm' }, roles: { chief: { authority: { withinRole: ['message.send'], crossRole: [] } }, pm: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
// Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect`
// what fetch gives for the sign-in proxy's redirect under redirect: 'manual'.
const STUB = `(() => {
const real = window.fetch.bind(window);
window.__posts = []; window.__answers = [];
const timeout = window.setTimeout.bind(window);
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
const q = new URLSearchParams(location.search);
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const u = String(url);
if (u === '/api/config') {
const c = await (await real(url, init)).json();
return answer(200, { ...c, ...(q.has('sso') ? { sso: true } : {}), ...(q.has('send') ? { send: true } : {}), ...(q.has('decide') ? { send: true, decide: true } : {}), ...(q.has('tester') ? { tester: true } : {}) });
}
if (!u.startsWith('/api/decisions/')) return real(url, init);
window.__posts.push({ url: u, method: init.method, headers: init.headers, body: init.body, cache: init.cache, redirect: init.redirect });
const a = window.__answers.shift();
if (!a) throw new TypeError('no answer queued');
if (a.hold) await new Promise(r => { window.__release = r; });
if (a.reject) throw new TypeError('Failed to fetch');
if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } };
return answer(a.status, a.body);
};
})();`;
async function setup() {
const root = mkdtempSync(join(tmpdir(), 'webui-decide-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
const grant = { method: 'host-enroll', host: 'web1', address: '192.0.2.10', port: 22, user: 'jarvis', sudo: true, fingerprints: [] };
const open = grantDecision(store, { grant, close: null });
store.run('UPDATE decisions SET question=? WHERE id=?', `Enroll web1? ${HOSTILE}`, open);
const deploy = grantDecision(store, { action: 'deploy', grant, close: null });
const bare = grantDecision(store, { close: null });
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser();
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const has = sel => b.evaluate(`!!document.querySelector(${JSON.stringify(sel)})`);
const posts = () => b.evaluate('window.__posts');
const queue = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`);
const click = step => b.evaluate(`document.querySelector('#decide-section [data-decide="${step}"]').click(), true`);
const page = async (query, id) => {
await b.navigate(`${base}?${query}#/inbox/${id}`);
await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Decision") && !document.querySelector("#bus-view [aria-busy]")');
// The config read redraws once it lands; a refresh tick after it settles the page.
await new Promise(r => setTimeout(r, 400));
};
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, store, base, open, deploy, bare, wait, text, has, posts, queue, click, page, settle, done };
}
const CONFIRM = (s, action, choice, text) => ({ status: 200, body: { id: s.open, question: `Enroll web1? ${HOSTILE}`, action, choice, text, grant: { method: 'host-enroll', host: 'web1' }, confirmation: 'c'.repeat(64) } });
test('J16: no controls without sso and decide, read-only reasons, terminal lines kept, and only an eligible grant gets the panel', { timeout: 120000 }, async () => {
const s = await setup();
try {
// Without SSO: no panel, no button, and the reason names both flags.
await s.page('x', s.open);
assert.equal(await s.has('#decide-section'), false);
assert.equal(await s.has('[data-decide]'), false);
assert.match(await s.text('#bus-view'), /needs sign-in \(--sso\) and writes enabled \(--send\)/);
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
// SSO without --send: read-only, still the terminal lines.
await s.page('sso', s.open);
assert.equal(await s.has('#decide-section'), false);
assert.match(await s.text('#bus-view'), /read-only for decisions: it was started without writes enabled \(--send\)/);
// send alone, without decide, still draws nothing.
await s.page('sso&send', s.open);
assert.equal(await s.has('#decide-section'), false);
// A tester Console never answers, whatever the config says.
await s.page('sso&decide&tester', s.open);
assert.equal(await s.has('#decide-section'), false);
// Eligible: the panel, both buttons, the escaped question and the terminal lines too.
await s.page('sso&decide', s.open);
assert.equal(await s.text('#decide-section h2'), 'Answer in Console');
assert.deepEqual(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].map(e => [e.dataset.decide, e.textContent, e.disabled])'), [['approve', 'Approve', false], ['deny', 'Deny', false]]);
assert.match(await s.text('#decide-section'), /Grant: method host-enroll · host web1\./);
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 2);
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
assert.ok((await s.text('#bus-view .s1-q')).includes(HOSTILE));
// A grant raise with another action, and a credential.mint with no grant, keep the terminal only.
for (const id of [s.deploy, s.bare]) {
await s.page('sso&decide', id);
assert.equal(await s.has('#decide-section'), false);
assert.equal(await s.has('[data-decide]'), false);
}
// The Inbox says so, and marks only the eligible row.
await s.b.navigate(`${s.base}?sso&decide#/inbox`);
await s.wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && document.querySelectorAll("#bus-view .s1-decs > li").length === 3');
await s.settle();
assert.match(await s.text('#bus-view'), /A grant request can be approved or denied here, after a confirmation\./);
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#bus-view .s1-decs > li")].filter(li => li.textContent.includes("Approve or Deny here")).length'), 1);
assert.deepEqual(await s.posts(), []);
} finally { await s.done(); }
});
test('J17: one POST per click, confirm then answer, cancel, refusals and an unknown outcome never retry', { timeout: 120000 }, async () => {
const s = await setup();
const enc = encodeURIComponent;
try {
await s.page('sso&decide', s.open);
// Approve asks for a confirmation and answers nothing. The button holds while the request is out.
await s.queue({ ...CONFIRM(s, 'approve', 'yes', 'Yes'), hold: true });
await s.click('approve');
await s.wait('document.querySelector("#decide-section [data-decide=approve]")?.textContent === "Checking…"');
assert.equal(await s.b.evaluate('[...document.querySelectorAll("#decide-section [data-decide]")].every(e => e.disabled)'), true);
await s.click('approve');
await s.b.evaluate('window.__release(), true');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
let sent = await s.posts();
assert.equal(sent.length, 1);
assert.deepEqual(sent[0], { url: `/api/decisions/${enc(s.open)}/confirmation`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'yes' }), cache: 'no-store', redirect: 'manual' });
assert.equal(await s.text('#decide-section [data-decide=confirm]'), 'Confirm approve');
assert.match(await s.text('#decide-section .s1-confirm'), /Approving starts this grant\./);
assert.equal(await s.b.evaluate('document.activeElement?.dataset.decide ?? null'), 'confirm');
assert.equal(await s.b.evaluate('window.injected ?? null'), null);
// A refresh keeps the confirmation open.
await s.settle();
assert.equal(await s.has('#decide-section [data-decide=confirm]'), true);
// Cancel drops it without a request.
await s.click('cancel');
await s.wait('document.querySelector("#decide-section [data-decide=approve]")');
assert.equal((await s.posts()).length, 1);
// A refusal the Console wrote shows its code and message; a 408 or a lost answer is unavailable.
await s.queue({ status: 409, body: { error: 'decision-stale', message: 'The choices changed. Review the decision again.' } });
await s.click('deny');
await s.wait('document.querySelector("#decide-status")');
assert.equal(await s.text('#decide-status'), 'Not answered here: The choices changed. Review the decision again. (decision-stale).');
await s.queue({ status: 408, body: { error: 'x', message: 'y' } });
await s.click('deny');
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable"');
await s.queue({ reject: true });
await s.click('deny');
await s.wait('document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
assert.equal(await s.b.evaluate('document.querySelector("#decide-status code")?.textContent'), 'confirmation-unavailable');
assert.equal((await s.posts()).length, 4);
// A reply that doesn't match what was asked is not a confirmation.
await s.queue({ status: 200, body: { ...CONFIRM(s, 'approve', 'yes', 'Yes').body } });
await s.click('deny');
await s.wait('document.querySelector("#decide-status code")?.textContent === "confirmation-unavailable" && document.querySelectorAll("#decide-section [data-decide]:not([disabled])").length === 2');
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
// An unknown outcome at the answer says to refresh, drops the confirmation and sends nothing again.
await s.queue(CONFIRM(s, 'deny', 'no', 'No'));
await s.click('deny');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
assert.match(await s.text('#decide-section .s1-confirm'), /Denying starts nothing\./);
await s.queue({ status: 503, body: { error: 'outcome-unknown', message: 'The answer may have reached the bus. Refresh before trying again.' } });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status code")?.textContent === "outcome-unknown"');
assert.match(await s.text('#decide-status'), /Nothing was sent again\.$/);
sent = await s.posts();
assert.equal(sent.length, 7);
assert.deepEqual(sent[6], { url: `/api/decisions/${enc(s.open)}/resolve`, method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ choice: 'no', confirmation: 'c'.repeat(64) }), cache: 'no-store', redirect: 'manual' });
assert.equal(await s.has('#decide-section [data-decide=confirm]'), false);
await s.settle();
await s.settle();
assert.equal((await s.posts()).length, 7, 'the page sent again by itself');
// A confirmed approve: one answer, a receipt, and no buttons after it, across refreshes.
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
await s.click('approve');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
await s.queue({ status: 200, body: { id: s.open, op: 'resolved', choice: 'yes', grant: 'started' } });
await s.click('confirm');
await s.wait('document.querySelector("#decide-status")?.textContent.startsWith("Approved")');
assert.equal(await s.text('#decide-status'), 'Approved: you chose “yes”, Yes. The grant executor started. History below shows it once the bus is read again.');
await s.settle();
assert.equal(await s.has('[data-decide]'), false);
assert.equal((await s.posts()).length, 9);
// Closed elsewhere: the next read drops the panel and shows the recorded answer with its provenance.
s.store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)', s.open, '2026-10-22T00:00:00.000Z', 'resolved', 'jason', 'yes', 'console');
await s.page('sso&decide', s.open);
assert.equal(await s.has('#decide-section'), false);
assert.match(await s.text('#bus-view'), /Resolved: jason chose “yes” via console/);
assert.equal(await s.b.evaluate('document.querySelectorAll("#bus-view [data-copy]").length'), 0);
} finally { await s.done(); }
});
test('a sign-in redirect at either step signs the page out and answers nothing', { timeout: 120000 }, async () => {
const s = await setup();
try {
await s.page('sso&decide', s.open);
await s.queue({ redirect: true });
await s.click('approve');
await s.wait('document.body.classList.contains("signed-out")');
assert.equal((await s.posts()).length, 1);
await s.page('sso&decide', s.open);
await s.queue(CONFIRM(s, 'approve', 'yes', 'Yes'));
await s.click('approve');
await s.wait('document.querySelector("#decide-section [data-decide=confirm]")');
await s.queue({ redirect: true });
await s.click('confirm');
await s.wait('document.body.classList.contains("signed-out")');
assert.equal((await s.posts()).length, 2);
} finally { await s.done(); }
});
+154
View File
@@ -0,0 +1,154 @@
// Row 252 (#1753, design sections 4 and 5): the Console decision routes on the human listener, signed
// in through the fake OP in helpers/fake-oidc.mjs on loopback. The bus transport is a recording stub,
// so this covers the listener's gates and the session it passes; decision-http.test.mjs covers the
// endpoint's own table and console-decide.test.mjs the bus. The public name is console.example.test,
// sent only as a Host header to the test's own listener. sso.test.mjs pins its own log lines, so the
// decide lines are checked here.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { readFileSync } from 'node:fs';
import { startServer } from '../src/serve.mjs';
import { createSso, SESSION_COOKIE } from '../src/sso.mjs';
import { DECISION_REFUSALS } from '../src/decision-http.mjs';
import { consoleCall } from '../src/bus.mjs';
import { close } from './fixture.mjs';
import { fakeOidc } from './helpers/fake-oidc.mjs';
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
const DIGEST = 'b'.repeat(64);
const PREVIEW = { id: 'd1', business: 'demo', question: 'Enroll web1?', digest: DIGEST, approve: { key: 'yes', text: 'Yes' }, deny: { key: 'no', text: 'No' }, grant: { method: 'host-enroll', host: 'web1' } };
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
req.end(body);
});
}
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
// The fake OP, createSso over it, and the human listener. `server` overrides startServer options.
// The decision transport records each call and answers a preview or a resolve.
async function setup(t, server = {}) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = op.issuer('a');
const config = { publicOrigin: ORIGIN, providers: [{ id: 'a', issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: 'a' }, algs: ['RS256'], subjects: [{ sub: 'user-a', name: 'person-a' }] }] };
const lines = [];
const sso = await createSso(config, { secrets: new Map([['a', iss.clientSecret]]), fetch: op.fetch, log: l => lines.push(l) });
t.after(() => sso.close());
const calls = [];
const decisionWrite = async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
return verb === 'decision.preview.console' ? { ...PREVIEW } : { id: args.id, op: 'resolved', choice: args.choice };
};
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => []]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async () => ({ id: 'msg-0001' }), decisionWrite, log: l => lines.push(l), ...server });
t.after(() => close(web));
const port = web.address().port;
const signIn = async () => {
const start = await call(port, { path: '/auth/login' });
assert.equal(start.status, 303);
const cookie = [start.headers['set-cookie']].flat()[0].split(';')[0];
const g = op.grant(iss, start.headers.location);
const q = new URLSearchParams({ code: g.code, state: new URL(start.headers.location).searchParams.get('state') });
const r = await call(port, { path: `/auth/callback?${q}`, headers: { cookie } });
return [r.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0')).split(';')[0];
};
const post = (cookie, path, body, headers = {}) => call(port, { method: 'POST', path, headers: { ...writeHeaders(cookie), ...headers }, body: JSON.stringify(body) });
return { port, iss, calls, lines, signIn, post };
}
const refused = (r, code) => {
assert.equal(r.status, DECISION_REFUSALS[code][0], `${code}: ${r.status} ${JSON.stringify(r.body)}`);
assert.deepEqual(r.body, { error: code, message: DECISION_REFUSALS[code][1] });
};
test('a signed-in owner confirms and answers, with the session\'s provider, issuer and sub as the subject', async t => {
const env = await setup(t);
const session = await env.signIn();
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.body.decide, true);
assert.equal(config.body.sso, true);
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
assert.equal(c.status, 200, JSON.stringify(c.body));
const r = await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation });
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.deepEqual(r.body, { id: 'd1', op: 'resolved', choice: 'yes' });
const subject = { provider: 'a', iss: env.iss.issuer, sub: 'user-a' };
assert.deepEqual(env.calls, [
{ verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject } },
{ verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject, business: 'demo' } },
]);
assert.match(env.lines.at(-1), / decide user="person-a" decision="d1" step=resolve choice="yes"$/);
assert.equal(env.lines.some(l => l.includes(c.body.confirmation) || l.includes(session.split('=')[1])), false);
});
test('the listener gates: no session, a cross-origin page, a loopback Host, a GET, and a sign-out between the steps', async t => {
const env = await setup(t);
refused(await env.post(null, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'sign-in-required');
const session = await env.signIn();
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { origin: 'https://evil.test' }), 'cross-origin');
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }, { 'sec-fetch-site': 'cross-site' }), 'cross-origin');
const loop = await call(env.port, { method: 'POST', path: '/api/decisions/d1/confirmation', host: `127.0.0.1:${env.port}`, headers: { cookie: session, 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
assert.equal(loop.status, 403);
refused(await call(env.port, { path: '/api/decisions/d1/confirmation', headers: { cookie: session } }), 'method-not-allowed');
assert.equal(env.calls.length, 0);
const c = await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' });
assert.equal(c.status, 200);
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
assert.equal(out.status, 303);
refused(await env.post(session, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'sign-in-required');
assert.equal(env.calls.length, 1);
// A new session can't use the old session's confirmation.
const again = await env.signIn();
refused(await env.post(again, '/api/decisions/d1/resolve', { choice: 'yes', confirmation: c.body.confirmation }), 'confirmation-required');
assert.equal(env.calls.length, 1);
});
test('J16: without --send or a decision transport the routes are read-only and /api/config says no; startServer refuses decide without sso or send', async t => {
for (const server of [{ send: null }, { decisionWrite: null }]) {
const env = await setup(t, server);
const session = await env.signIn();
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.body.decide, false);
assert.equal(config.body.sso, true);
refused(await env.post(session, '/api/decisions/d1/confirmation', { choice: 'yes' }), 'read-only');
assert.equal(env.calls.length, 0);
}
const write = async () => ({});
await assert.rejects(startServer({ port: 0, decisionWrite: write, send: async () => ({}) }), /need --sso and --send/);
await assert.rejects(startServer({ port: 0, tester: true, testerSocket: {}, decisionWrite: write }), /need --sso and --send/);
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
t.after(() => close(plain));
const port = plain.address().port, host = `127.0.0.1:${port}`;
const r = await call(port, { method: 'POST', path: '/api/decisions/d1/confirmation', host, headers: { origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: '{"choice":"yes"}' });
refused(r, 'read-only');
const config = await call(port, { path: '/api/config', host });
assert.equal(config.body.decide, false);
assert.equal('sso' in config.body, false);
});
test('N4: decision.resolve has no Console route, the --send caller names no decision verb, and consoleCall takes only the two console verbs', async t => {
const env = await setup(t);
const session = await env.signIn();
for (const verb of ['decision.resolve', 'decision.resolve.console', 'decision.preview.console']) {
for (const method of ['GET', 'POST']) {
const r = await call(env.port, { method, path: `/api/bus/${verb}`, headers: writeHeaders(session), ...(method === 'POST' ? { body: '{}' } : {}) });
assert.equal(r.status, 404, `${method} ${verb}`);
}
}
assert.equal(env.calls.length, 0);
const cli = readFileSync(new URL('../src/cli.mjs', import.meta.url), 'utf8');
assert.doesNotMatch(cli, /['"]decision\./);
const c = consoleCall({ dataRoot: '/nonexistent', socket: '/nonexistent', business: 'demo', cli: '/nonexistent/console-cli.mjs', timeoutMs: 1000 });
for (const verb of ['decision.resolve', 'decision.seen', 'message.send', 'inbox']) {
await assert.rejects(c(verb, { id: 'd1', choice: 'yes' }, { subject: { provider: 'a', iss: 'i', sub: 's' } }), /the Console does not call/);
}
});
+205
View File
@@ -0,0 +1,205 @@
// Row 252 (#1753): the Console decision endpoint, driven with a fake request, response and bus
// transport. The real bus side is in packages/bus/tests/console-decide.test.mjs and the page side
// in the browser tests.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BusReadError } from '../src/bus.mjs';
import { DECISION_REFUSALS, createConfirmations, decisionHTTP } from '../src/decision-http.mjs';
const ORIGIN = 'https://console.test';
const DIGEST = 'a'.repeat(64);
const PREVIEW = {
id: 'd1', business: 'demo', question: 'Enroll host?', digest: DIGEST,
approve: { key: 'yes', text: 'Yes, enroll it' }, deny: { key: 'no', text: 'No' },
grant: { method: 'host-enroll', host: 'h1' },
};
function request({ method = 'POST', body = {}, headers = {}, raw } = {}) {
const h = { origin: [ORIGIN], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'], ...headers };
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
const data = Buffer.from(raw ?? JSON.stringify(body));
return { method, headersDistinct: h, async *[Symbol.asyncIterator]() { yield data; } };
}
function response() {
const r = { status: null, headers: null, text: '' };
r.writeHead = (s, h) => { r.status = s; r.headers = h; };
r.end = (t = '') => { r.text += t; };
r.json = () => JSON.parse(r.text);
return r;
}
function setup({ answer = () => ({ ...PREVIEW }), resolve = (a) => ({ id: a.id, op: 'resolved', choice: a.choice, grant: 'started' }), now } = {}) {
const session = { name: 'jason', provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' };
const calls = [], lines = [];
const s = {
session, calls, lines, current: session,
confirmations: createConfirmations(now ? { now } : {}),
decide: {
origin: ORIGIN,
call: async (verb, args, ctx) => {
calls.push({ verb, args, ctx });
return verb === 'decision.preview.console' ? answer(args) : resolve(args);
},
},
};
s.run = async (path, req) => {
const res = response();
await decisionHTTP(req, res, { path, decide: s.decide, session: s.session, live: () => s.current, confirmations: s.confirmations, log: (l) => lines.push(l) });
return res;
};
s.confirm = (choice = 'yes', id = 'd1') => s.run(`/api/decisions/${id}/confirmation`, request({ body: { choice } }));
s.resolve = (body, id = 'd1') => s.run(`/api/decisions/${id}/resolve`, request({ body }));
return s;
}
const refused = (res, code) => {
assert.equal(res.status, DECISION_REFUSALS[code][0], `${code}: ${res.text}`);
assert.deepEqual(res.json(), { error: code, message: DECISION_REFUSALS[code][1] });
assert.equal(res.headers['cache-control'], 'no-store');
};
test('a confirmation then a resolve answers once with the held choice and digest, and a second resolve refuses', async () => {
const s = setup();
const c = await s.confirm('yes');
assert.equal(c.status, 200);
const body = c.json();
assert.match(body.confirmation, /^[a-f0-9]{64}$/);
assert.deepEqual({ ...body, confirmation: 'x' }, { id: 'd1', question: 'Enroll host?', action: 'approve', choice: 'yes', text: 'Yes, enroll it', grant: PREVIEW.grant, confirmation: 'x' });
assert.deepEqual(s.calls[0], { verb: 'decision.preview.console', args: { id: 'd1' }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' } } });
const r = await s.resolve({ choice: 'yes', confirmation: body.confirmation });
assert.equal(r.status, 200);
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'yes', grant: 'started' });
assert.deepEqual(s.calls[1], { verb: 'decision.resolve.console', args: { id: 'd1', choice: 'yes', digest: DIGEST }, ctx: { subject: { provider: 'fake-oidc', iss: 'https://idp.test', sub: 'owner-1' }, business: 'demo' } });
// J12: the confirmation is spent, so a repeat never reaches the bus.
refused(await s.resolve({ choice: 'yes', confirmation: body.confirmation }), 'confirmation-required');
assert.equal(s.calls.length, 2);
assert.equal(s.lines.length, 3);
assert.match(s.lines[0], / decide user="jason" decision="d1" step=confirmation action=approve$/);
assert.match(s.lines[1], / step=resolve choice="yes" grant=started$/);
assert.doesNotMatch(s.lines.join('\n'), new RegExp(body.confirmation));
});
test('deny resolves without a grant field', async () => {
const s = setup({ resolve: (a) => ({ id: a.id, op: 'resolved', choice: a.choice }) });
const c = (await s.confirm('no')).json();
assert.equal(c.action, 'deny');
assert.equal(c.text, 'No');
const r = await s.resolve({ choice: 'no', confirmation: c.confirmation });
assert.deepEqual(r.json(), { id: 'd1', op: 'resolved', choice: 'no' });
});
test('the gates before the body: method, read-only, sign-in and same origin', async () => {
const s = setup();
refused(await s.run('/api/decisions/d1/confirmation', request({ method: 'GET' })), 'method-not-allowed');
const res = response();
await decisionHTTP(request({ body: { choice: 'yes' } }), res, { path: '/api/decisions/d1/confirmation', decide: null, session: s.session, live: () => s.session, confirmations: s.confirmations, log: () => {} });
refused(res, 'read-only');
const out = response();
await decisionHTTP(request({ body: { choice: 'yes' } }), out, { path: '/api/decisions/d1/confirmation', decide: s.decide, session: null, live: () => null, confirmations: s.confirmations, log: () => {} });
refused(out, 'sign-in-required');
for (const headers of [{ origin: ['https://evil.test'] }, { origin: null }, { origin: [ORIGIN, ORIGIN] }, { 'sec-fetch-site': ['same-site'] }, { 'sec-fetch-site': null }]) {
refused(await s.run('/api/decisions/d1/confirmation', request({ body: { choice: 'yes' }, headers })), 'cross-origin');
}
assert.equal(s.calls.length, 0);
});
test('a bad body, id or content type refuses before the bus', async () => {
const s = setup();
const path = '/api/decisions/d1/confirmation';
refused(await s.run(path, request({ body: { choice: 'yes' }, headers: { 'content-type': ['text/plain'] } })), 'invalid-request');
refused(await s.run(path, request({ raw: '{' })), 'invalid-request');
refused(await s.run(path, request({ raw: '[]' })), 'invalid-request');
refused(await s.run(path, request({ body: { choice: 'yes', extra: 1 } })), 'invalid-request');
refused(await s.run(path, request({ body: { choice: 1 } })), 'invalid-request');
refused(await s.run(path, request({ raw: JSON.stringify({ choice: 'y'.repeat(5000) }) })), 'request-too-large');
refused(await s.run('/api/decisions/..%2Fx/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
refused(await s.run('/api/decisions/%E0%A4%A/confirmation', request({ body: { choice: 'yes' } })), 'decision-not-found');
refused(await s.resolve({ choice: 'yes' }), 'invalid-request');
refused(await s.resolve({ choice: 'yes', confirmation: 'a'.repeat(64), more: 1 }), 'invalid-request');
assert.equal(s.calls.length, 0);
});
test('a choice that is neither key is decision-stale, and a malformed preview is confirmation-unavailable', async () => {
refused(await setup().confirm('maybe'), 'decision-stale');
for (const bad of [{ ...PREVIEW, id: 'd2' }, { ...PREVIEW, digest: 'short' }, { ...PREVIEW, deny: { key: 'yes', text: 'No' } }, { ...PREVIEW, grant: null }, null]) {
const s = setup({ answer: () => bad });
refused(await s.confirm('yes'), 'confirmation-unavailable');
assert.equal(s.confirmations.size, 0);
}
});
test('bus refusals map by code, and an unknown or missing code is unavailable at preview and outcome-unknown at resolve', async () => {
const cases = [
['human-required', 'human-required'], ['agent-required', 'human-required'], ['access-revoked', 'human-required'],
['peercred-required', 'peercred-required'], ['decision-not-eligible', 'decision-not-eligible'],
['decision-closed', 'decision-closed'], ['decision-stale', 'decision-stale'], ['invalid-choice', 'decision-stale'],
['decision-not-found', 'decision-not-found'], ['outcome-unknown', 'confirmation-unavailable'], ['weird', 'confirmation-unavailable'],
];
for (const [bus, code] of cases) {
const s = setup({ answer: () => { throw new BusReadError(bus); } });
refused(await s.confirm('yes'), code);
}
refused(await setup({ answer: () => { throw new Error('boom'); } }).confirm('yes'), 'confirmation-unavailable');
for (const [bus, code] of [['decision-closed', 'decision-closed'], ['outcome-unknown', 'outcome-unknown'], ['weird', 'outcome-unknown']]) {
const s = setup({ resolve: () => { throw new BusReadError(bus); } });
const c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), code);
// Nothing retries, and the confirmation stays spent.
assert.equal(s.calls.length, 2);
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
}
for (const reply of [null, { id: 'd2', op: 'resolved', choice: 'yes' }, { id: 'd1', op: 'refused', choice: 'yes' }, { id: 'd1', op: 'resolved', choice: 'no' }]) {
const s = setup({ resolve: () => reply });
const c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'outcome-unknown');
}
});
test('a confirmation binds the session, decision and choice, and any mismatch spends it', async () => {
const s = setup();
let c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'no', confirmation: c.confirmation }), 'confirmation-required');
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
c = (await s.confirm('yes')).json();
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }, 'd2'), 'confirmation-required');
c = (await s.confirm('yes')).json();
const other = setup();
other.confirmations = s.confirmations;
other.session = { ...s.session };
other.current = other.session;
refused(await other.resolve({ choice: 'yes', confirmation: c.confirmation }), 'confirmation-required');
refused(await s.resolve({ choice: 'yes', confirmation: 'not-a-token' }), 'confirmation-required');
assert.equal(s.calls.filter((x) => x.verb === 'decision.resolve.console').length, 0);
assert.equal(other.calls.length, 0);
});
test('a session that ends between the steps refuses before the bus', async () => {
const s = setup();
s.current = null;
refused(await s.confirm('yes'), 'sign-in-required');
assert.equal(s.calls.length, 0);
s.current = s.session;
const c = (await s.confirm('yes')).json();
s.current = { ...s.session };
refused(await s.resolve({ choice: 'yes', confirmation: c.confirmation }), 'sign-in-required');
assert.equal(s.calls.length, 1);
// A sign-out during the preview also refuses, and no confirmation is issued.
const t = setup({ answer: () => { t.current = null; return { ...PREVIEW }; } });
refused(await t.confirm('yes'), 'sign-in-required');
assert.equal(t.confirmations.size, 0);
});
test('confirmations expire after the TTL, cap at the limit without dropping a live one, and are one use', () => {
let t = 0;
const store = createConfirmations({ ttlMs: 1000, max: 2, now: () => t });
const bind = { session: 's', business: 'demo', decision: 'd1', choice: 'yes', digest: DIGEST };
const a = store.issue(bind), b = store.issue(bind);
assert.equal(store.issue(bind), null);
assert.equal(store.size, 2);
assert.equal(store.consume(a, bind).digest, DIGEST);
assert.equal(store.consume(a, bind), null);
t = 1000;
assert.equal(store.consume(b, bind), null);
assert.equal(store.size, 0);
assert.match(store.issue(bind), /^[a-f0-9]{64}$/);
assert.equal(store.consume(undefined, bind), null);
assert.equal(store.consume('A'.repeat(64), bind), null);
});
+7 -3
View File
@@ -50,14 +50,18 @@ test('shipped strings are unchanged, and the web keeps its write boundary', () =
['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
// No Resolve control. The board's Seen and reply are app.js's POSTs; bus.js has four explicit writes:
// Row 252 (#1753, mos-1321): the one decision control is the Console's grant Approve and Deny, one
// fetch(url, ...) in decidePost, called only for a decision's confirmation and its resolve. Its labels
// come from ACTIONS, and the doesNotMatch below keeps any literal Resolve, Decide or Approve label out
// of the markup. The board's Seen and reply are app.js's POSTs; bus.js's other explicit writes are
// row 76's message to a role, row 122's human Settings update, row 125's token-limit reset (one path,
// to the same Settings route) and row 148's spec and docs save. Row 165 (#1662) adds the role
// instance add or change to /api/instances, and row 65 the routing table's save to /api/routes.
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
const writes = read('bus.js').toString().match(/fetch\([^;]*method:\s*['"](POST|PUT|PATCH|DELETE)[^;]*;/g) ?? [];
assert.deepEqual(writes, ["fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 7);
assert.deepEqual(writes, ["fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), cache: 'no-store', redirect: 'manual' });", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/settings',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision,reset:[JSON.parse(path)]}),cache:'no-store',redirect:'manual'});", "fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/instances',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});", "fetch('/api/project-file', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(sent), cache: 'no-store', redirect: 'manual' });", "fetch('/api/bus/message', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to, body: text }), cache: 'no-store', redirect: 'manual' });"]);
assert.equal(read('bus.js').toString().split(/method:/).length - 1, 8);
assert.deepEqual([...read('bus.js').toString().matchAll(/decidePost\(([^,]*),/g)].map(m => m[1]), ['url', '`/api/decisions/${enc(a.id)}/confirmation`', '`/api/decisions/${enc(a.id)}/resolve`']);
// Row 155 (#1649): the one fetch(url, ...) posts only to these three routes, each a literal.
// Row 176 (#1674): the tester name form adds a second /api/name caller.
assert.deepEqual([...read('bus.js').toString().matchAll(/assistantPost\(([^,]*),/g)].map(m => m[1]), ['url', "'/api/settings'", "'/api/name'", "'/api/assistant-stop'", "'/api/name'"]);
@@ -183,3 +183,14 @@ test('row 177 W4: the main listener refuses x-mosaic-tester on chat reads and wr
assert.equal((await http(web, '/api/chat/threads', { headers: signed })).status, 200);
assert.deepEqual(calls, ['threads']);
});
test('Row 252: the tester listener has no decision answer routes, and its config never offers them', async t => {
const s = await testerFixture(t);
for (const step of ['confirmation', 'resolve']) {
const r = await post(s.web, `/api/decisions/d1/${step}`, step === 'confirmation' ? { choice: 'yes' } : { choice: 'yes', confirmation: 'a'.repeat(64) });
assert.equal(r.status, 404, r.text);
assert.equal(r.body.error, 'not-found');
}
assert.equal((await http(s.web, '/api/config')).body.decide, false);
assert.deepEqual(s.calls, []);
});
+1 -1
View File
@@ -417,7 +417,7 @@ plugin_checks() {
# Row 181: the tables a bus writer fills without an event in the same transaction, in schema
# order. A restore drops their rows after STAMP as it drops events, so the guard counts them too.
# BUILD.md for row 181 names each writer; the T46 guard classifies every table in the schema.
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens thread_shares instance_names usage_records"
COUNTED="role_claims thread_claims decision_events messages deliveries task_snapshots projects threads sessions session_defaults thread_opens instance_names usage_records"
STORE_JS='
const {DatabaseSync}=require("node:sqlite");const {pathToFileURL}=require("node:url");