Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0048911e | ||
|
|
7965efd0fb | ||
|
|
77dd43c9e3 | ||
|
|
8bf442be90 | ||
|
|
d73ea24ead | ||
|
|
69bf7f5ca7 | ||
|
|
378a76b854 | ||
|
|
ddfb29af7c | ||
|
|
9c97329833 | ||
|
|
a849d89b73 |
@@ -0,0 +1,137 @@
|
||||
# Row 236 build notes: #436b Gitea grant path (#1742, schema 3j-3k)
|
||||
|
||||
Builder Megavolt, reviewer Dewey, gate owner Sage. Draft; the results sections are filled in after the
|
||||
runs on Sage's gate.
|
||||
|
||||
## Sources
|
||||
|
||||
- Brief: `docs/plans/2026-10-20_agent-credentials.md`, "Brief: #436b Gitea grant path, build (schema 3j-3k)",
|
||||
blob 9259ba2a1.
|
||||
- Design: `agents/megavolt/work/436b/DESIGN.md` r5 at 1be535aca (sha256 68df02cd…f4), unedited. Scope:
|
||||
sections 2 to 7, and section 9's X-G1 to X-G15 and mutants. Section 10 (GitHub half) is out of scope.
|
||||
- Build carry: `agents/megavolt/work/436b/BUILD-CARRY.md` at 3330c901b (N9, N10).
|
||||
- Schema label 3j-3k per sage-2237. If 264a or 206 lands first, this rebases onto a new branch name and reruns
|
||||
the whole bus suite, the schema.test counts and the T46 lists.
|
||||
|
||||
## What changed
|
||||
|
||||
- `packages/bus/src/gitea-grant.mjs` (new): the grant body check, the admin slot (set, rotate, remove), the
|
||||
admin and seat API calls, the start-time scan (`giteaStart`, `openGiteaGrants`, `tokenUnknown`).
|
||||
- `packages/bus/src/broker.mjs`: the gitea executor (steps 0 to 8), settle, restart reconcile, revoke and fire,
|
||||
sweep, status, and the `grants.*` verbs.
|
||||
- `packages/bus/src/credentials.mjs`: `held` pairs at start; the register and claim paths the executor uses.
|
||||
- `packages/bus/src/migrations/3j-3k.sql`, `schema.sql`, `store.mjs`: `gitea_users` and `gitea_grants`, new
|
||||
tables only.
|
||||
- `packages/bus/src/runtime.mjs`, `server.mjs`, `human-cli.mjs`: wiring of the start scan and the verbs.
|
||||
- `packages/cli/src/grants.mjs` (new), `cli.mjs`, `scripts/mosaic`: `mosaic grants`.
|
||||
- Tests: `gitea-grant.test.mjs`, `gitea-migration.test.mjs`, `gitea-grant-docker.test.mjs` (X-G3),
|
||||
`fake-gitea-api.mjs`, `fixtures/schema-3j.sql` (02da6a2ae's schema.sql), `cli/tests/grants.test.mjs`, and the
|
||||
pin updates in the migration tests, `schema.test.mjs`, `channel-proof.test.mjs` and `stack-release.test.mjs`
|
||||
(T46).
|
||||
- READMEs of bus and cli.
|
||||
|
||||
## Choices and assumptions
|
||||
|
||||
Each is a reading of the design where it left room. Dewey may rule otherwise.
|
||||
|
||||
1. Admin rotation is due once today is past `rotateBy` (the date itself is still good).
|
||||
2. A seat-token rotation moves the record at step 8, after the old token's delete (step 7).
|
||||
3. A step 7 failure leaves the grant applied, with the old id in `serviceLeft` and the notice "The previous
|
||||
token X is still live".
|
||||
4. A failure after the commit step's rename returns `settle-pending`; the next start settles it.
|
||||
5. tester-cli keeps its 5 s timeout; the grant verbs answer at once and the executor runs after.
|
||||
6. A revoke of a token-unknown agent takes both ids from that decision's last commit step.
|
||||
7. A user adopted at restart is upserted in the same transaction as its `grant.failed interrupted`.
|
||||
8. `#giteaUnlink` (first-grant commit undo) uses a temporary reference dated `9999-12-31`, so it is never due
|
||||
while it lives.
|
||||
9. X-G12's org-team case: the fake models team membership as collaborator reach on the repository, which is
|
||||
what `/user/repos` shows (F11, F12).
|
||||
10. X-G15's look-alikes are stored under `action.refused` and under a non-grant kind, since the 3j trigger
|
||||
refuses the human's NULL pair on `action.allowed`. The trigger cases of X-G13 (session, NULL pair,
|
||||
`grant.host.enrolled`) are in `grant-migration.test.mjs`.
|
||||
11. Step 6 faults: a pre-rename failure is injected by making `gitea.tmp` a directory; a step 8 store fault by
|
||||
a TEMP trigger on `gitea_grants`.
|
||||
12. The credentials-verb revoke close is tracked, so `giteaIdle()` waits for it.
|
||||
13. `openGiteaGrants` gives `agent: null` for a spend with no step yet, so `status.running` can show a null
|
||||
agent; the CLI prints it empty.
|
||||
14. N9: a restart ignores a settle-pending commit with a `grant.revoked` after it and ends it `revoked` with
|
||||
no message (notify false). A 404 on a delete by id counts as done.
|
||||
15. N10: `serviceLeft` is a list in every body and answer.
|
||||
16. A restart claims the agent's key for each open grant it undoes. One executor runs per agent
|
||||
(grant-busy), so a killed child leaves at most one open grant per agent. If a store ever held two, the
|
||||
second's claim fails and the undo runs with no admin: its token is left and named in `serviceLeft`,
|
||||
never deleted unclaimed.
|
||||
17. X-G7 changes the binding with a fixture decision whose hash differs (row 234's G9 pattern), since the
|
||||
broker keeps its own copy of the business. The admin url check takes row 199's `parseGiteaUrl`, which
|
||||
accepts the origin with or without a trailing slash.
|
||||
18. r2, Dewey B1 (dewey-0781): step 8 and both settle branches (forward, back) check for a `grant.revoked`
|
||||
of the agent after the decision's raise, in the same transaction as their write. A revoke that came
|
||||
while step 7's or the settle's delete awaited took the record and both tokens, so the grant ends
|
||||
`revoked` with the human's notice, as a revoke of a running executor ends it (X-G11), and no record is
|
||||
written back. N9's restart case stays silent: a restart never settles a revoked rotation.
|
||||
19. r2, Dewey B2: a sweep retry of `tokens` or `login` checks the user's id first, as the revoke close does,
|
||||
against the `gitea_users` row (it survives a fire). A 404 skips with `gitea-user-gone`, a 200 with
|
||||
another id with `gitea-user-foreign`, any other status leaves the entry. Skips go in `grant.swept` and
|
||||
the sweep answer as `skipped: [{agent, user, id, reason}]`; a skipped entry is not left, so the next
|
||||
sweep does not retry it.
|
||||
20. r3, Dewey N5 (dewey-0783): the settle's 401 branch and its grant-token-unknown branch take the same
|
||||
revoke check. A revoke during the whoami ends the grant `revoked`, not `grant-lost`, and the
|
||||
token-unknown branch neither holds the agent again nor writes `grant-token-unknown`, so no start
|
||||
names a file the revoke unlinked.
|
||||
|
||||
## Named for DEFERRED (r2, Dewey N1-N4)
|
||||
|
||||
None is a small local change, so none is in r2. sage-2388: all four DEFERRED; N3 and N4 get a bus README
|
||||
sentence each in r3 (a left admin token is deleted by hand at Gitea; a running executor keeps the admin
|
||||
api it started with).
|
||||
|
||||
- N1: an admin rotate cut between the mint and the slot write leaves a token at Gitea that no event names.
|
||||
A `grant.admin rotate-begin {oldName}` before the mint would close it; that is a new event shape.
|
||||
- N2: `adminTokenLeft` is `unknown` when `/api/v1/token` gives no name. Which 1.27 F-fact shows the name
|
||||
needs a check against the container, not a code change.
|
||||
- N3: admin set or remove records `adminTokenLeft: null`, so a left token drops out of status. Carrying it
|
||||
forward lets `rotate --finish` delete by name under a slot of another account or url; refusing remove
|
||||
while one is left blocks the human's remove when Gitea is down. Either needs a ruling.
|
||||
- N4: rotate or remove while a gitea claim is held. Refusing it blocks the human's remove during a run;
|
||||
the running executor keeps the api it began with. Needs a ruling or a line in the README.
|
||||
|
||||
## Q188
|
||||
|
||||
No test creates a grant except through fixtures or the human's yes on a scratch store. No test reaches
|
||||
git.mosaicstack.dev, tasks.woltje.com or any provider. Unit tests use the fake Gitea on 127.0.0.1; X-G3 uses a
|
||||
throwaway gitea/gitea:1.27.1 container from the local image (never pulled), removed after. Every token is a
|
||||
fake or a scratch-container token; values are compared with `assert.ok`, never printed.
|
||||
|
||||
## Runs
|
||||
|
||||
(filled in after Sage's CLEAR)
|
||||
|
||||
## Mutants
|
||||
|
||||
(section 9's list, each with the test that kills it; filled in after the runs)
|
||||
|
||||
A static pass over the 39 mutants (all apply, problems=0) found five with no test to kill them. Four tests
|
||||
were added before any mutant run:
|
||||
|
||||
- M8, M9 (user or mint step written after its call) and M16 (grant.result written before its call): "X-G6
|
||||
and X-G8: the user and mint steps are written before their calls, and each result after its call". It
|
||||
reads the store from the fake while user-create and token-create are at Gitea, and checks every result's
|
||||
status.
|
||||
- M29 (fork parent not checked): "X-G4: a fork by the seat user of another repository than the project
|
||||
refuses fork-foreign".
|
||||
- M28 (memory and file revoked after the Gitea delete): "X-G11: a revoke unloads memory and removes the file
|
||||
before its Gitea delete", which holds the DELETE open and reads the file and state.
|
||||
- M37 (evidence without its kind filter): "X-G15: a look-alike spend under another kind with the grant writer
|
||||
pair is not an open grant".
|
||||
|
||||
r2 tests, each red on 8bf442be9 by reading:
|
||||
|
||||
- B1: "X-G11: a revoke during a rotation step 7 ends it revoked, and step 8 writes no record back" and
|
||||
"X-G11: a revoke while a sweep settles a rotation, forward or back, ends it revoked with no record".
|
||||
On 8bf442be9 step 8 and the settle write the record and `grant.applied` (forward) or `grant.failed
|
||||
interrupted settled:back` after the revoke deleted it.
|
||||
- B2: "X-G14: a sweep retries a fire only for the user it fired: a remade or gone user is skipped and
|
||||
named". On 8bf442be9 the retry fires the remade user (token list, delete, PATCH) and has no `skipped`.
|
||||
- N5 (r3, sage-2388): "X-G11: a revoke during a settle whoami that finds a foreign token ends it revoked, not
|
||||
held" and "X-G11: a revoke during a settle whoami that gets a 401 ends it revoked, not lost". On 77dd43c9e
|
||||
they end `grant-token-unknown` (the agent back in tokenUnknown) and `grant-lost`.
|
||||
@@ -0,0 +1,127 @@
|
||||
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
|
||||
|
||||
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
|
||||
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
|
||||
Review record: round 2 approved as duckworth-0041 (commit 2a1d17da7) on 48a3fb96e; viewer carry c204bef73 after it.
|
||||
|
||||
## What was built
|
||||
|
||||
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
|
||||
atomic write, the lock and the receipt.
|
||||
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
|
||||
- `scripts/mosaic`: sends `access` to the CLI.
|
||||
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
|
||||
and the limits on peers and subjects.
|
||||
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
|
||||
|
||||
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
|
||||
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
|
||||
|
||||
## The lock file (not in PLAN.md; added at Sage's request)
|
||||
|
||||
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
|
||||
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
|
||||
read, the check, the temp write and the rename. It is removed in a `finally`.
|
||||
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
|
||||
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
|
||||
is written.
|
||||
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
|
||||
refusal names the path and the pid. A human checks that no write is running and removes the file. This
|
||||
is the crash case: the holder died mid-write, and a human clears it.
|
||||
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
|
||||
holder that died in that gap leaves an empty lock; a human removes it.
|
||||
|
||||
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
|
||||
race below is gone, not accepted.
|
||||
|
||||
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
|
||||
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
|
||||
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
|
||||
it, and the second rename silently replaces the first. The revision check did not catch it, because both
|
||||
had read the old revision before either wrote.
|
||||
|
||||
Known limits, stated plainly:
|
||||
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
|
||||
no reclaim. The refusal says which pid and which path.
|
||||
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
|
||||
3. The lock is per access file. It does not cover the bus's own reads.
|
||||
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
|
||||
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
|
||||
The bytes are never mixed.
|
||||
|
||||
Tests for the lock (all in `access.test.mjs`):
|
||||
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
|
||||
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
|
||||
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
|
||||
file, the lock and the receipt are unchanged.
|
||||
- "the lock is mode 0600 while held and removed after a refused write".
|
||||
|
||||
Tests for the write path and the refusals (added in this round):
|
||||
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
|
||||
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
|
||||
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
|
||||
Exit 3, the file path in the message, nothing written.
|
||||
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
|
||||
throws if called, and the file is 0664. The refusal must come first.
|
||||
|
||||
## The human proof, and the bus behavior behind test 10
|
||||
|
||||
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
|
||||
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
|
||||
one function, `proveHuman` in `access.mjs`, for R4 to swap.
|
||||
|
||||
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
|
||||
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
|
||||
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
|
||||
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
|
||||
receipt are unchanged.
|
||||
|
||||
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
|
||||
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
|
||||
|
||||
## Context
|
||||
|
||||
`accessContextFor(system, env)`:
|
||||
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
|
||||
row 192's candidate).
|
||||
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
|
||||
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
|
||||
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
|
||||
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
|
||||
`instances`. A business file that fails to load fails the command.
|
||||
|
||||
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
|
||||
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
|
||||
reads `console` through `checkConsole`.
|
||||
|
||||
## Mutant list (one per refusal; run on Sage's go, before review)
|
||||
|
||||
| # | Mutant | Expected kill |
|
||||
|---|---|---|
|
||||
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
|
||||
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
|
||||
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
|
||||
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
|
||||
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
|
||||
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
|
||||
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
|
||||
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
|
||||
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
|
||||
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
|
||||
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
|
||||
| M10 | the lock is not taken | "a live holder's lock refuses" |
|
||||
|
||||
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
|
||||
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
|
||||
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
|
||||
|
||||
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
|
||||
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
|
||||
|
||||
## Not done in this row
|
||||
|
||||
- `--peers` and the federation key: row 203.
|
||||
- `access.reload`: row R4.
|
||||
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
|
||||
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Brief: row 253 F5 follow-up, supervised legacy AS_USER calls in the case scripts
|
||||
|
||||
Sage, 2026-10-22. Source: row 253's DESIGN-C4 addendum (Gander 968db0bcd, :111-124) and Duckworth's
|
||||
addendum ruling (duckworth-0111, RULING-c4-addendum.md at 45288e584, F5 and C4).
|
||||
|
||||
## Brief: supervised legacy AS_USER calls
|
||||
|
||||
Owner Gander. Reviewer Duckworth. Gate owner Sage. Starts after row 253 lands.
|
||||
|
||||
Why: when a terminal is present, sudo runs the command in a new session (use_pty), and it can't relay
|
||||
SIGKILL. Row 253 puts its new config query and peer probe under an inline supervisor that scans the
|
||||
session it owns. It leaves the older calls in `case1-accept.sh` as they are. These are the `timeout 330 ...
|
||||
talk` path (case1-accept.sh.txt:305 in Gander's frozen snapshot) and the other AS_USER diagnostics (:326,
|
||||
:335, :355, :396). Each of these wraps sudo in a GNU timeout outside the uid drop. That has the same gap:
|
||||
when it fires, it kills timeout's process group, and the command keeps running in its separate session.
|
||||
|
||||
What to build:
|
||||
1. Every remaining AS_USER call in the case scripts runs under row 253's supervisor text. That's the same
|
||||
bytes, pinned by row 253's C5 test, with its own inner and outer bounds and reasons. The talk path
|
||||
keeps its 300 s conversation and its stdout and stderr diagnostics.
|
||||
2. Or, for any call that can't take the supervisor, the header says it isn't supervised and why, and the
|
||||
DEFERRED entry stays open for that call by name.
|
||||
|
||||
Tests: extend case-sudo-fixture.mjs cases in both fake modes (exec and session hop) to each changed call.
|
||||
Each one needs a no-leftover assertion and its exact reason. Mutants: drop the supervisor from the talk path,
|
||||
and drop the outer bound from one diagnostic. Each must be killed.
|
||||
|
||||
Gate: Duckworth approves the manifest, the touched test files run whole, every scripts/test-*.sh runs,
|
||||
and the mutants are killed.
|
||||
@@ -20,8 +20,9 @@ Two steps need `scripts/stack-release.sh` flags that don't exist at 02da6a2ae:
|
||||
The fix is one small row, approved in mos-1246: `activate --send` and `activate --no-sso`. `--no-sso`
|
||||
takes the Console flags from the rollback set of the activation that turned `--sso` on, the way `sso_base`
|
||||
already does for a rollback to a copy without `--sso`. It drops `--send`, and its plan line says so. `--send`
|
||||
refuses unless the run's Console flags hold `--sso`, given or carried (F39). test-release.sh gets cases for
|
||||
both. Darkwing builds it, and a seat other than Sage and Darkwing reviews it. Brief:
|
||||
refuses unless the run's Console flags hold `--sso`, given or carried (F39). The cases for both go in
|
||||
`packages/cli/tests/stack-release.test.mjs`. F39 named test-release.sh, but that suite tests `release.sh`
|
||||
and never calls `stack-release.sh`, so it runs whole and unchanged. Darkwing builds it, and a seat other than Sage and Darkwing reviews it. Brief:
|
||||
`docs/plans/2026-10-22_activate-send-nosso.md`. It lands and is re-pinned before the window, on Mos's go,
|
||||
run by Jason or Mos. The steps below assume it, and draft 3 names the new copy.
|
||||
|
||||
|
||||
+6
-5
@@ -33,7 +33,7 @@ Gaps found while working go to `docs/plans/DEFERRED.md`, not here.
|
||||
|
||||
<!-- mosaic-queue:begin -->
|
||||
|
||||
Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
Generated from `docs/plans/queue.json` revision 3108 by `queue render`. Do not edit between the markers; change the queue with `scripts/mosaic queue`.
|
||||
|
||||
| # | Piece | Owner | Issues | State | After | Gate | Brief | Note |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
@@ -238,10 +238,10 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
|
||||
| 199 | MVP T1 item 6: a credential store per agent, own git identity, pushes to a remote (G11, G13; mos-0974) | megavolt; reviewers duckworth | #1697 | done | — | Duckworth approves DESIGN.md; Mos rules on the authority model; Duckworth approves the manifest; bus, business, cli and tasks suites and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_agent-credentials.md` § Brief: a credential store per agent, own git identity, pushes to a remote | Landed cb5fdb064 on refactor (base 76134dfde), pushed. Patch-id 828fa21a matches git diff 00d4cc1f6..86963d281 on the 37 manifest paths; docs/TOOLS.md 3-way with row 200, hunk unchanged. Manifest 35f900fd, Duckworth approve r1 29779. Gate on 9e1085816+patch: packages 2735/0, scripts 429/0; landed blobs equal the gate tree. |
|
||||
| 200 | stack-release.sh activate --sso for the Q175 phase 2 window (row 192 F3; chained on 192) | launchpad; reviewers webby | #1698 | done | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_stack-release-sso.md` § Brief: stack-release.sh activates a Console with --sso (Q175 phase 2 prerequisite) | Landed 9e1085816 on refactor (base 722136772), pushed. Patch-id 7ebfa6a5 matches git diff 00d4cc1f6..9df40878f on the 3 manifest paths. Manifest b3ead55c, Webby approve r3 29772. Gate on 9df40878f: packages 2675/0, scripts 429/0. Mutants 13/14, M12 ruled equivalent (sage-1446). |
|
||||
| 201 | FED R1: access.json loader and validator, and covers (#414) | gizmoduck; reviewers duckworth | #1699 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R1, the access.json loader and validator | Landed afad4993f on refactor (squash of gizmoduck ae3c36f24..44e36fc4c, manifest 225597146daf...0c520). patch-id ba604ad5e matches the reviewed diff on all 3 paths. Gate on 44e36fc4c in the slot (gizmoduck-0060): business 173/0, 10 scripts 422/0, mutants 44/44. Duckworth approve 29581. |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | in-review, round 2, request posted | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 202 | FED R5: mosaic access list, grant and revoke (#414) | quackerjack; reviewers duckworth | #1700 | done | — | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R5, mosaic access list, grant and revoke | Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage) |
|
||||
| 203 | FED R2: the federation key in the system config (#414) | quackerjack; reviewers webby | #1701 | briefed | — | Webby approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_federation-r1-r2-r5.md` § Brief: FED R2, the federation key in the system config | Chained on row 192's candidate (Q177): starts on 192's candidate, lands after 192 (Sage) |
|
||||
| 204 | 193a: access level check widened to viewer (#1691) | morgana; reviewers duckworth | #1702 | done | — | Duckworth approves the manifest; business suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193a, the access level check widened to viewer | Landed d0bbc5c1c on refactor (base c9f374284). Patch-id 92c18aea matches git diff 44e36fc4c..f869fd062 on the 3 manifest paths. Manifest d38de8c9, Duckworth approve 29595. Gate 2789/0 on 4385b9ec1, mutants 7/7. Unpushed. |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | briefed | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | — |
|
||||
| 205 | 193b1a: the message filter (#1703; FED R3 and 193b1b build on it) | morgana; reviewers duckworth | #1703 | in-progress | 196 done | Duckworth approves the manifest; bus suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1a, the message filter | — |
|
||||
| 206 | 193b2: thread shares, schema 3k-3l after the #436b build (#1691; live needs a --migrate window with Jason) | morgana; reviewers duckworth | #1704 | queued | 205 done, 234 done, 236 done, 275 done | Duckworth approves the manifest; bus suite, cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b2, thread shares (schema 3l) | — |
|
||||
| 207 | 193c: mosaic access user verbs and --level viewer (#1691) | morgana; reviewers duckworth | #1705 | queued | 202 done | Duckworth approves the manifest; cli suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193c, mosaic access user verbs and --level viewer | — |
|
||||
| 208 | 193d: Console shared list, share controls and viewer read-only (#1691; also after FED R6 when filed) | morgana; reviewers duckworth | #1706 | queued | 192 done, 206 done | Duckworth approves the manifest; webui suite and every scripts/test-*.sh green on Sage's gate; the mutants killed (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193d, the Console's shared list, share controls and viewer read-only | — |
|
||||
@@ -309,9 +309,10 @@ Generated from `docs/plans/queue.json` revision 3102 by `queue render`. Do not e
|
||||
| 270 | 264c T1 item 7: compaction count, exact for Pi | gizmoduck; reviewers duckworth | #1775 | briefed | 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Test T3 Pi half; Claude shows unknown with the Vikunja 466 reason (Q6). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 271 | 264d T1 item 7: clear context through row 194's lifecycle stop | gizmoduck; reviewers duckworth | #1776 | briefed | 194 done, 269 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T4 and T6; rebased onto 194's landing with no 194 code copied (Q4). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 272 | 264e T1 item 7: mosaic session CLI and Console chooser | gizmoduck; reviewers duckworth | #1777 | briefed | 264 done | Duckworth approves this slice's manifest. Its design tests run whole plus every touched suite on Sage's slot, and its mutants are killed (RULINGS Q1). Tests T5 legacy single human and T7 Console half; controls for unlanded verbs refuse with a reason (Q1, Q7). (sage) | `docs/plans/2026-10-21_t1-sessions.md` § T1 item 7: independent sessions, compaction counts and clear context | — |
|
||||
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | briefed | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | — |
|
||||
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | briefed | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | — |
|
||||
| 273 | Q175 phase 2: stack-release.sh activate --send and --no-sso (F39, mos-1246) | darkwing; reviewers filbert | #1778 | in-progress | — | Filbert approves the manifest. stack-release.test.mjs and stack-release-external.test.mjs run whole plus scripts/test-release.sh on Sage's slot, and the brief's mutants are killed. (sage) | `docs/plans/2026-10-22_activate-send-nosso.md` § Brief: activate --send and --no-sso | — |
|
||||
| 274 | FED R3: broker user principal (#414) | morgana; reviewers dewey | #1779 | in-progress | — | Dewey approves the manifest. The bus and harness suites and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_federation-r3.md` § Brief: FED R3, the broker user principal | — |
|
||||
| 275 | 193b1b: viewer verbs and board refusals (also after FED R4 when filed) | morgana; reviewers duckworth | #1780 | briefed | 205 done, 274 done | Duckworth approves the manifest. The bus suite and every scripts/test-*.sh are green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-20_human-users-build.md` § Brief: 193b1b, viewer verbs and board refusals | — |
|
||||
| 276 | Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts | gander; reviewers duckworth | #1781 | briefed | 253 done | Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed. (sage) | `docs/plans/2026-10-22_case-sudo-legacy-followup.md` § Brief: supervised legacy AS_USER calls | — |
|
||||
|
||||
<!-- mosaic-queue:end -->
|
||||
|
||||
|
||||
+235
-20
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"canonicalRoot": "/mnt/storage/src/mosaic-stack",
|
||||
"revision": 3102,
|
||||
"revision": 3108,
|
||||
"rows": [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -21441,7 +21441,7 @@
|
||||
"closes": [
|
||||
1700
|
||||
],
|
||||
"state": "in-review",
|
||||
"state": "done",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -21568,15 +21568,12 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"claim": {
|
||||
"seat": "quackerjack",
|
||||
"op": "quackerjack-202-start"
|
||||
},
|
||||
"claim": null,
|
||||
"note": "Chained on row 201's candidate (Q177), not its done: claimable now, lands after 201 and rebases onto its landing (Sage)",
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T19:42:59.570Z",
|
||||
"updatedAt": "2026-10-21T22:09:50.722Z",
|
||||
"updatedBy": "duckworth"
|
||||
"updatedAt": "2026-10-22T02:01:48.311Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 203,
|
||||
@@ -21711,7 +21708,7 @@
|
||||
"closes": [
|
||||
1703
|
||||
],
|
||||
"state": "briefed",
|
||||
"state": "in-progress",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -21732,12 +21729,15 @@
|
||||
"duckworth"
|
||||
],
|
||||
"review": null,
|
||||
"claim": null,
|
||||
"claim": {
|
||||
"seat": "morgana",
|
||||
"op": "morgana-205-start-1"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-20T19:44:12.910Z",
|
||||
"updatedAt": "2026-10-22T00:50:24.817Z",
|
||||
"updatedBy": "sage"
|
||||
"updatedAt": "2026-10-22T01:24:48.298Z",
|
||||
"updatedBy": "morgana"
|
||||
},
|
||||
{
|
||||
"id": 206,
|
||||
@@ -27031,7 +27031,7 @@
|
||||
"closes": [
|
||||
1778
|
||||
],
|
||||
"state": "briefed",
|
||||
"state": "in-progress",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -27047,12 +27047,15 @@
|
||||
"filbert"
|
||||
],
|
||||
"review": null,
|
||||
"claim": null,
|
||||
"claim": {
|
||||
"seat": "darkwing",
|
||||
"op": "darkwing-273-claim-1"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-22T00:49:02.640Z",
|
||||
"updatedAt": "2026-10-22T00:54:21.474Z",
|
||||
"updatedBy": "sage"
|
||||
"updatedAt": "2026-10-22T01:26:14.459Z",
|
||||
"updatedBy": "darkwing"
|
||||
},
|
||||
{
|
||||
"id": 274,
|
||||
@@ -27064,7 +27067,7 @@
|
||||
"closes": [
|
||||
1779
|
||||
],
|
||||
"state": "briefed",
|
||||
"state": "in-progress",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
@@ -27080,12 +27083,15 @@
|
||||
"dewey"
|
||||
],
|
||||
"review": null,
|
||||
"claim": null,
|
||||
"claim": {
|
||||
"seat": "morgana",
|
||||
"op": "morgana-274-start-1"
|
||||
},
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-22T00:51:10.987Z",
|
||||
"updatedAt": "2026-10-22T00:55:05.247Z",
|
||||
"updatedBy": "sage"
|
||||
"updatedAt": "2026-10-22T01:25:32.568Z",
|
||||
"updatedBy": "morgana"
|
||||
},
|
||||
{
|
||||
"id": 275,
|
||||
@@ -27128,6 +27134,44 @@
|
||||
"createdAt": "2026-10-22T00:51:54.534Z",
|
||||
"updatedAt": "2026-10-22T00:55:47.065Z",
|
||||
"updatedBy": "sage"
|
||||
},
|
||||
{
|
||||
"id": 276,
|
||||
"piece": "Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts",
|
||||
"owner": "gander",
|
||||
"issues": [
|
||||
1781
|
||||
],
|
||||
"closes": [
|
||||
1781
|
||||
],
|
||||
"state": "briefed",
|
||||
"previousState": null,
|
||||
"required": false,
|
||||
"requiredSince": null,
|
||||
"gate": "Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed.",
|
||||
"gateOwner": "sage",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-22_case-sudo-legacy-followup.md",
|
||||
"anchor": "Brief: supervised legacy AS_USER calls",
|
||||
"blob": "b0d0b7f76ef1933b70d7c0ff9ceef58bb3fe5c6b"
|
||||
},
|
||||
"after": [
|
||||
{
|
||||
"id": 253,
|
||||
"when": "done"
|
||||
}
|
||||
],
|
||||
"reviewers": [
|
||||
"duckworth"
|
||||
],
|
||||
"review": null,
|
||||
"claim": null,
|
||||
"note": null,
|
||||
"blockedReason": null,
|
||||
"createdAt": "2026-10-22T01:58:23.353Z",
|
||||
"updatedAt": "2026-10-22T01:59:07.003Z",
|
||||
"updatedBy": "sage"
|
||||
}
|
||||
],
|
||||
"log": [
|
||||
@@ -109069,6 +109113,177 @@
|
||||
"receipt": "ok sage-275-briefed-0022 rev 3102 row 275 queued→briefed"
|
||||
},
|
||||
"viewSha": "f25628bc6b477edaff12ba424225bf82f3cff21bdf45e678496e5f2e3a862893"
|
||||
},
|
||||
{
|
||||
"rev": 3103,
|
||||
"op": "morgana-205-start-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 205,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "morgana",
|
||||
"at": "2026-10-22T01:24:48.298Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 205,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok morgana-205-start-1 rev 3103 row 205 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "db811f7752ed36459fb361eee1062815639e8439688c28eb38ed656fbf949d26"
|
||||
},
|
||||
{
|
||||
"rev": 3104,
|
||||
"op": "morgana-274-start-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 274,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "morgana",
|
||||
"at": "2026-10-22T01:25:32.568Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 274,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok morgana-274-start-1 rev 3104 row 274 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "9de6fc1f247f1ed4b392596dc98d8e16fdd3c113ab28eea4adf73e25e5547a56"
|
||||
},
|
||||
{
|
||||
"rev": 3105,
|
||||
"op": "darkwing-273-claim-1",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 273,
|
||||
"to": "in-progress",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "darkwing",
|
||||
"at": "2026-10-22T01:26:14.459Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 273,
|
||||
"from": "briefed",
|
||||
"to": "in-progress",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok darkwing-273-claim-1 rev 3105 row 273 briefed→in-progress"
|
||||
},
|
||||
"viewSha": "bef4326bf903ca8059c300f8e52189af85d82879ca600066e7f8f211f8f19d85"
|
||||
},
|
||||
{
|
||||
"rev": 3106,
|
||||
"op": "sage-253f5-add-b",
|
||||
"verb": "add",
|
||||
"args": {
|
||||
"piece": "Row 253 F5: supervise the legacy AS_USER talk and diagnostics in the case scripts",
|
||||
"gate": "Duckworth approves the manifest. The touched test files run whole, every scripts/test-*.sh is green on Sage's gate, and the mutants are killed.",
|
||||
"brief": "docs/plans/2026-10-22_case-sudo-legacy-followup.md#Brief: supervised legacy AS_USER calls",
|
||||
"issues": [
|
||||
1781
|
||||
],
|
||||
"note": null,
|
||||
"owner": "gander",
|
||||
"gateOwner": "sage",
|
||||
"after": [
|
||||
{
|
||||
"id": 253,
|
||||
"when": "done"
|
||||
}
|
||||
],
|
||||
"reviewers": [
|
||||
"duckworth"
|
||||
],
|
||||
"required": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T01:58:23.353Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 276,
|
||||
"from": null,
|
||||
"to": "queued",
|
||||
"brief": {
|
||||
"path": "docs/plans/2026-10-22_case-sudo-legacy-followup.md",
|
||||
"anchor": "Brief: supervised legacy AS_USER calls",
|
||||
"blob": "b0d0b7f76ef1933b70d7c0ff9ceef58bb3fe5c6b"
|
||||
},
|
||||
"receipt": "ok sage-253f5-add-b rev 3106 row 276 none→queued"
|
||||
},
|
||||
"viewSha": "60688f2cadd887471133a79bf1ce84416dbe2d9afa7322302f290933be5450ab"
|
||||
},
|
||||
{
|
||||
"rev": 3107,
|
||||
"op": "sage-276-briefed-0022",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 276,
|
||||
"to": "briefed",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T01:59:07.003Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 276,
|
||||
"from": "queued",
|
||||
"to": "briefed",
|
||||
"round": null,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-276-briefed-0022 rev 3107 row 276 queued→briefed"
|
||||
},
|
||||
"viewSha": "c81c29470cb5a4f2b391ffbbc222f1ebeae863c181ddf124049e396929b03ceb"
|
||||
},
|
||||
{
|
||||
"rev": 3108,
|
||||
"op": "sage-202-done-0022",
|
||||
"verb": "move",
|
||||
"args": {
|
||||
"id": 202,
|
||||
"to": "done",
|
||||
"reason": null,
|
||||
"candidate": null,
|
||||
"evidence": null,
|
||||
"issue": null
|
||||
},
|
||||
"by": "sage",
|
||||
"at": "2026-10-22T02:01:48.311Z",
|
||||
"semantics": 2,
|
||||
"result": {
|
||||
"row": 202,
|
||||
"from": "in-review",
|
||||
"to": "done",
|
||||
"round": 2,
|
||||
"issue": null,
|
||||
"candidate": null,
|
||||
"receipt": "ok sage-202-done-0022 rev 3108 row 202 in-review→done round 2"
|
||||
},
|
||||
"viewSha": "8d8d0082fe3209698c9c2feb86bad94df7539447e233c9c6dc26e627a5f17f60"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+15
-4
@@ -225,6 +225,11 @@ an operator inspect content and delivery evidence after an uncertain result.
|
||||
| `hosts.remove` | `{host}`; the human or the tester. Deletes the record, writes `grant.host.removed` and removes the installer key. A `host_grants` record for the host is `host-in-use`, a guarded event naming a host line left on the host `host-line-left`. |
|
||||
| `hosts.revoke` | `{agent,host,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true` (row 235, #1741, r6 section 6). Anyone else gets `revoke-refused`. The tester check reads `bus.tester` again at the call, `tester-dev-only`. One transaction deletes the `host_grants` record and writes `grant.revoked` with `hostLine:"pending"`. With no await between, the key leaves memory and its file goes, and a `host.run` or executor in flight aborts. Then `remove <user> <tag>` runs on the host and `grant.revoke.closed` records `hostLine:"removed"` or `hostLineLeft`. A revoke with no record still runs, with `granted:false`. Returns `{agent,host,tag,granted,keyFile,hostLine or hostLineLeft}`. A restart with a pending marker runs the remove once the bus is up; `hosts.status` lists its tag under `revoking`. |
|
||||
| `hosts.sweep` | `{host}`; the human or the tester (r6 X3). Runs `list <user>` and removes each listed tag this business recorded for the host in a `grant.step` or `grant.revoked` that no live grant holds. Another business's line and an unrecorded `mosaic-` line stay. Writes `grant.host.swept {host,removed,left}`; `hostLineLeft` then counts from it. Returns `{host,removed,hostLineLeft}`. |
|
||||
| `grants.status` | `{service:"gitea"}`; the human, the tester or the chief (row 236, #1742, #436b r5). The admin slot's url, account and rotate-by date with `rotationDue`, `adminTokenLeft`, the `gitea_users` and `gitea_grants` records, approved decisions not yet run (`spentAtNextStart`), grants `running` or `settlePending`, agents held as `tokenUnknown`, the `serviceLeft` entries a sweep retries, and revokes still `revoking`. No token. |
|
||||
| `grants.run` | `{decision}`; the human only. Starts the Gitea executor of a `credential.mint` decision with a gitea grant the human said yes to, as the yes itself does (X1). Step 0's refusals come back with no record: `not-authorized`, `decision-consumed`, `grant-token-unknown`, `grant-busy`, `gitea-not-configured`. Returns `{decision,agent,started:true}`; the outcome is a `grant.applied` or `grant.failed` and a notice. |
|
||||
| `grants.revoke` | `{service:"gitea",agent,reason}`; the human, the business's assistant, or the tester when `bus.tester` is exactly `true`. The token leaves memory and its file goes, an executor in flight aborts, then one transaction deletes the `gitea_grants` record and writes `grant.revoked` with the token ids to delete. The deletes run with the admin token after C1 to C4, and `grant.revoke.closed` records `serviceLeft`, a list. `credentials.revoke AGENT gitea` and `credentials.delete` do the same for a stack-made user; delete also deletes every token of the user and sets `prohibit_login` (fire). |
|
||||
| `grants.sweep` | `{service:"gitea"}`; the human or the tester. Closes open revokes, settles open rotations again, and retries each `serviceLeft` entry. A fire retry (`tokens`, `login`) first checks the user's id as the revoke close does, and a gone or remade user is skipped with `gitea-user-gone` or `gitea-user-foreign`. Writes `grant.sweep` and `grant.swept {left,skipped,settled,closed}`. |
|
||||
| `grants.admin.set`, `grants.admin.rotate`, `grants.admin.remove` | The human only (#436b r5 section 3). set takes `{service,url,account,rotateBy,token}`, checks C1 to C4 with the token, then writes the slot under `<configDir>/secrets/grants/<business>/gitea`; a slot already there is `grant-admin-exists`. rotate `{service,rotateBy}` mints a successor admin token and names the old one in `adminTokenLeft`; `{service,finish:true}` deletes it. Rotation voids the approved decisions not yet run, as their `adminSha256` no longer matches. remove drops the slot. Each writes `grant.admin` with no secret. A left admin token that set or remove drops from `adminTokenLeft`, or one shown as `unknown`, has to be deleted by hand at Gitea. A running executor, revoke close or sweep keeps the admin api it started with: rotate voids only decisions not yet run, and remove takes effect from the next run. |
|
||||
|
||||
Each of the three leaves one receipt in the organization's `changes.jsonl`: the actor and a
|
||||
`detail` of `{verb,agent,kind,date or reason}`, never a value. A change to the store alone writes
|
||||
@@ -711,10 +716,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3j, SHA-256
|
||||
`0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c`.
|
||||
It has 18 tables, 72 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3j`, WAL,
|
||||
`schema.sql` is v3k, SHA-256
|
||||
`aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0`.
|
||||
It has 20 tables, 72 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3k`, 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.
|
||||
@@ -882,6 +887,12 @@ step runs in one transaction, and a failure leaves the store at 3i. The 3i fixtu
|
||||
`tests/fixtures/schema-3i.sql`, is pinned from 8fede5fc6's tree, and `tests/grant-migration.test.mjs`
|
||||
covers the step and the trigger.
|
||||
|
||||
Schema 3k (row 236, #1742, #436b r5 section 7) adds only two tables: `gitea_users`, the Gitea
|
||||
users the stack made (user, id and the nonce in their full name), and `gitea_grants`, the live Gitea
|
||||
grants (user, token id and name, projects, rotate-by date and decision), whose key references its
|
||||
`gitea_users` row. Each write goes in one transaction with a `grant.*` event. The step runs in one
|
||||
transaction, and a failure leaves the store at 3j. `tests/gitea-migration.test.mjs` covers it.
|
||||
|
||||
The managed runner calls usage.begin before an actual harness turn and usage.end
|
||||
after it, including a failed/stopped turn. Each takes only {turn: deliveredMessageId}.
|
||||
The broker requires the bound role holder and its delivery, binds run identity,
|
||||
|
||||
@@ -394,3 +394,24 @@ CREATE TRIGGER events_grant_actor BEFORE INSERT ON events
|
||||
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;
|
||||
|
||||
+1001
-3
File diff suppressed because it is too large
Load Diff
@@ -116,20 +116,26 @@ export class Credentials {
|
||||
#revoked = new Set();
|
||||
// Row 210 (design D5, D9): instance:host.<id> -> the AbortController of the call in flight.
|
||||
#inflight = new Map();
|
||||
// Row 236 (#436b section 5): instance:service entries whose file memory never loads, the
|
||||
// grant-token-unknown pairs. A revoke or a new issue clears one.
|
||||
#held = new Set();
|
||||
#storeRoot;
|
||||
#repoRoots;
|
||||
#dataRoot;
|
||||
// storeRoot is <configDir>/secrets/agents (design D1); a reference with store: true must name the
|
||||
// path under it for its own business, agent and service.
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null }) {
|
||||
// path under it for its own business, agent and service. `held` lists "instance:service" keys whose
|
||||
// reference loads and whose file doesn't (#436b section 5).
|
||||
constructor({ references = {}, repoRoots = [], dataRoot, env = process.env, storeRoot = null, held = [] }) {
|
||||
this.#storeRoot = storeRoot;
|
||||
this.#repoRoots = repoRoots;
|
||||
this.#dataRoot = dataRoot;
|
||||
for (const k of held) this.#held.add(k);
|
||||
try {
|
||||
for (const [instance, services] of Object.entries(references))
|
||||
for (const [service, ref] of Object.entries(services)) {
|
||||
const date = this.#checkRef(instance, service, ref);
|
||||
this.#refs.set(instance + ':' + service, { ref, date });
|
||||
if (this.#held.has(instance + ':' + service)) continue;
|
||||
const value = this.#read(ref, env, service);
|
||||
if (value !== null) this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
}
|
||||
@@ -319,14 +325,66 @@ export class Credentials {
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
|
||||
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
|
||||
reload(instance, service, date) {
|
||||
const k = instance + ':' + service;
|
||||
const { ref } = this.#known(instance, service);
|
||||
if (this.#held.has(k) || !ref.store) deny('credential-reference');
|
||||
if (!day(date)) deny('credential-date');
|
||||
const value = this.#read(ref, {}, service);
|
||||
if (value === null) deny('credential-file');
|
||||
this.#refs.set(k, { ref: { ...ref, rotateBy: date }, date });
|
||||
this.#tokens.set(k, { ...value, date });
|
||||
this.#revoked.delete(k);
|
||||
}
|
||||
// Row 236 (#436b section 5, grant-token-unknown): memory drops the token, as revoke's memory step,
|
||||
// and a call in flight aborts. The file stays for the human to look at, and nothing loads it again
|
||||
// until a revoke or an issue.
|
||||
hold(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
this.#known(instance, service);
|
||||
this.#tokens.delete(k);
|
||||
this.#held.add(k);
|
||||
this.#inflight.get(k)?.abort();
|
||||
}
|
||||
held(instance, service) {
|
||||
return this.#held.has(instance + ':' + service);
|
||||
}
|
||||
// Row 236 (#436b section 5, settle): `operation` gets the token the store file holds now, read with
|
||||
// the constructor's checks, or null when there is no file. Memory is untouched. The result is checked
|
||||
// as use() checks it.
|
||||
async useFile(instance, service, operation) {
|
||||
const { ref } = this.#known(instance, service);
|
||||
if (!ref.store) deny('credential-reference');
|
||||
const value = this.#read(ref, {}, service);
|
||||
const result = await operation(value?.token ?? null);
|
||||
this.assertClean(result);
|
||||
if (value && JSON.stringify(result)?.includes(value.token)) deny('credential-leak');
|
||||
return result;
|
||||
}
|
||||
// Row 236 (#436b section 5, a first grant's open commit at restart): the store file goes, and with it
|
||||
// a token no record backs. An entry with a token in memory refuses: only a held entry is undone so.
|
||||
unlinkStore(instance, service) {
|
||||
const k = instance + ':' + service;
|
||||
const { ref } = this.#known(instance, service);
|
||||
if (this.#tokens.has(k) || !ref.store) deny('credential-reference');
|
||||
try {
|
||||
unlinkSync(ref.file);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
}
|
||||
// Memory first, then the store file (design section 6, D12). A file or env reference outside the
|
||||
// store is never unlinked; the caller turns its reference into store, so a restart finds nothing.
|
||||
revoke(instance, service) {
|
||||
const { ref } = this.#known(instance, service);
|
||||
this.#tokens.delete(instance + ':' + service);
|
||||
this.#revoked.add(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
// Row 210 (design D9): a host.run in flight for this agent and host is killed first.
|
||||
this.#inflight.get(instance + ':' + service)?.abort();
|
||||
if (ref.store) {
|
||||
@@ -425,9 +483,11 @@ export class Credentials {
|
||||
date,
|
||||
...(hostKey ? { fingerprint: hostKey.fingerprint, publicKey: hostKey.publicLine } : {}),
|
||||
state: !this.#tokens.has(key)
|
||||
? this.#revoked.has(key)
|
||||
? 'revoked'
|
||||
: 'not-issued'
|
||||
? this.#held.has(key)
|
||||
? 'held'
|
||||
: this.#revoked.has(key)
|
||||
? 'revoked'
|
||||
: 'not-issued'
|
||||
: service !== 'vikunja'
|
||||
? left <= 0
|
||||
? 'rotation-due'
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
// Row 236 (#1742, #436b design r5 at 1be535aca): the trusted half of the Gitea grant path, run in the bus
|
||||
// child. The broker gets it as `giteaGrant` and holds every store write and every step's order. This module
|
||||
// holds what touches the network or a secret file: the HTTPS calls to Gitea (each made through the
|
||||
// broker's `log`, which writes grant.call before and grant.result after), the admin slot (section 3c), the
|
||||
// binding's shape check (section 2), the admin checks C1 to C4 (section 3a), the start pre-load of held
|
||||
// pairs (section 5) and `giteaKeys`, the broker's narrow handle on Credentials.
|
||||
//
|
||||
// The admin token lives at <configDir>/secrets/grants/<business>/gitea/admin, 0600 in 0700 dirs, with
|
||||
// admin.json next to it ({url, account, rotateBy, setAt}, no secret). Both are written tmp, fsync, rename,
|
||||
// as row 199's issue writes. Neither value reaches an event, a message or a log.
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import {
|
||||
closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
|
||||
import { day, privateDir } from './credentials.mjs';
|
||||
import { definitionSha256, grantTag } from './host-grant.mjs';
|
||||
import { canonicalJson } from '../../business/src/util.mjs';
|
||||
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl } from '../../business/src/vocabulary.mjs';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const sha256 = (value) => createHash('sha256').update(canonicalJson(value)).digest('hex');
|
||||
// Section 2: the seat token's scopes, fixed by code (row 199 A2). Section 3b: the admin token's, exactly.
|
||||
// Both sorted, as Gitea reports scopes.
|
||||
export const SEAT_SCOPES = Object.freeze(['read:user', 'write:repository']);
|
||||
export const ADMIN_SCOPES = Object.freeze(['write:admin', 'write:repository', 'write:user']);
|
||||
// F6: the child-scope cap first ships in v1.27.0.
|
||||
export const MIN_VERSION = Object.freeze([1, 27, 0]);
|
||||
// Step 5: pages of 50, at most 20 of them.
|
||||
export const REPO_PAGE = 50;
|
||||
export const REPO_PAGES = 20;
|
||||
const TOKEN = /^[-A-Za-z0-9._~]+\n?$/;
|
||||
const MAX_BODY = 4 * 1024 * 1024;
|
||||
const CALL_MS = 15000;
|
||||
const META_KEYS = ['url', 'account', 'rotateBy', 'setAt'];
|
||||
const AT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/;
|
||||
|
||||
// C1: numbers, never strings, so 1.9.0 is below 1.27.0. A prerelease is below its release, so
|
||||
// 1.27.0-rc0 refuses (F6). Build metadata after "+" is ignored. Anything that doesn't parse refuses.
|
||||
const VERSION = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$/;
|
||||
export function versionAtLeast(value, floor = MIN_VERSION) {
|
||||
const m = typeof value === 'string' ? VERSION.exec(value) : null;
|
||||
if (!m) return false;
|
||||
const v = [m[1], m[2], m[3]].map(Number);
|
||||
for (let i = 0; i < 3; i++) if (v[i] !== floor[i]) return v[i] > floor[i];
|
||||
return !m[4];
|
||||
}
|
||||
|
||||
// Section 2's binding. Anything off refuses invalid-request; the broker checks the agent, its definition,
|
||||
// identity, publish list and the admin slot inside the raise transaction. rotateBy is a real calendar day
|
||||
// after `today`, the bus clock's UTC day: the rule row 199's host grants use (hostGrantCheck), since 199
|
||||
// names no other rotation window. Returns a fresh object with only the bound keys.
|
||||
export function giteaGrantCheck(grant, today) {
|
||||
const allowed = ['method', 'agent', 'rotateBy', 'target'];
|
||||
if (!grant || typeof grant !== 'object' || Array.isArray(grant)) deny('invalid-request');
|
||||
const own = Object.keys(grant);
|
||||
if (own.some((k) => !allowed.includes(k)) || allowed.some((k) => !own.includes(k))) deny('invalid-request');
|
||||
if (grant.method !== 'gitea') deny('invalid-request');
|
||||
if (typeof grant.agent !== 'string' || !ID_PATTERN.test(grant.agent)) deny('invalid-request');
|
||||
const target = grant.target;
|
||||
if (!target || typeof target !== 'object' || Array.isArray(target) || Object.keys(target).join() !== 'projects') deny('invalid-request');
|
||||
const projects = target.projects;
|
||||
if (!Array.isArray(projects) || projects.length < 1 || projects.length > 50) deny('invalid-request');
|
||||
if (projects.some((p) => typeof p !== 'string' || !ID_PATTERN.test(p)) || new Set(projects).size !== projects.length) deny('invalid-request');
|
||||
if (!day(grant.rotateBy) || grant.rotateBy <= today) deny('invalid-request');
|
||||
return { method: 'gitea', agent: grant.agent, target: { projects: [...projects] }, rotateBy: grant.rotateBy };
|
||||
}
|
||||
|
||||
// Section 2: over the agent's identity, its publish list and the remote of every project in it, since
|
||||
// step 5 checks against all of them. `projects` is the business's project map.
|
||||
export const identitySha256 = (role, projects) =>
|
||||
sha256({
|
||||
identity: role.identity ?? null,
|
||||
publish: role.publish ?? [],
|
||||
remotes: Object.fromEntries((role.publish ?? []).map((p) => [p, projects?.[p]?.remote ?? null])),
|
||||
});
|
||||
// Section 2: over the admin slot's metadata, never the token.
|
||||
export const adminSha256 = (meta) => sha256({ url: meta.url, account: meta.account, setAt: meta.setAt });
|
||||
// Step 3: `mosaic-<tag>-<decision>`. grantTag already starts with "mosaic-".
|
||||
export const tokenName = (business, agent, decision) => `${grantTag(business, agent, 'gitea')}-${decision}`;
|
||||
export { definitionSha256 };
|
||||
|
||||
// Section 3d: one HTTPS call. Never rejects: no answer (refused, reset, TLS failure, timeout, a body over
|
||||
// the cap) is status 0. A redirect is returned as its status, never followed. `auth` is {token} for the
|
||||
// `token` header or {basic: [account, token]} (F1). The result is {status, json, total}, total from
|
||||
// X-Total-Count; it holds no header and no request detail.
|
||||
export function send({ url, method, path, auth, body, ca, timeoutMs = CALL_MS }) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false,
|
||||
req = null;
|
||||
const end = (r) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve(r);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
req?.destroy();
|
||||
end({ status: 0, json: null, total: null });
|
||||
}, timeoutMs);
|
||||
try {
|
||||
const headers = {
|
||||
accept: 'application/json',
|
||||
authorization: auth.basic ? 'Basic ' + Buffer.from(auth.basic.join(':')).toString('base64') : 'token ' + auth.token,
|
||||
};
|
||||
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
||||
if (data) {
|
||||
headers['content-type'] = 'application/json';
|
||||
headers['content-length'] = String(data.length);
|
||||
}
|
||||
req = httpsRequest(new URL(path, url), { method, headers, ca, agent: false }, (res) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
res.on('data', (c) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) {
|
||||
req.destroy();
|
||||
end({ status: 0, json: null, total: null });
|
||||
} else chunks.push(c);
|
||||
});
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
|
||||
} catch {}
|
||||
const t = res.headers['x-total-count'];
|
||||
end({ status: res.statusCode, json, total: t === undefined || !/^\d+$/.test(t) ? null : Number(t) });
|
||||
});
|
||||
res.on('error', () => end({ status: 0, json: null, total: null }));
|
||||
});
|
||||
req.on('error', () => end({ status: 0, json: null, total: null }));
|
||||
req.end(data ?? undefined);
|
||||
} catch {
|
||||
end({ status: 0, json: null, total: null });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const segment = (value) => encodeURIComponent(String(value));
|
||||
|
||||
// C1 to C4 (section 3a), each call through the api's log. Throws the refusal code. Returns the admin
|
||||
// token's own name from C3 (F7), which rotate deletes by.
|
||||
export async function adminChecks(api, account) {
|
||||
const v = await api.version();
|
||||
if (v.status !== 200) deny('gitea-failed');
|
||||
if (!versionAtLeast(v.json?.version)) deny('gitea-too-old');
|
||||
const self = await api.self();
|
||||
if (self.status === 401 || self.status === 403) deny('grant-admin-account');
|
||||
if (self.status !== 200) deny('gitea-failed');
|
||||
if (self.json?.login !== account || self.json?.is_admin !== true) deny('grant-admin-account');
|
||||
const t = await api.tokenScopes();
|
||||
if (t.status !== 200) deny(t.status === 401 || t.status === 403 ? 'grant-admin-account' : 'gitea-failed');
|
||||
const scopes = t.json?.scopes;
|
||||
if (!Array.isArray(scopes) || [...scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
|
||||
const r = await api.reposCheck();
|
||||
if (r.status !== 200 || !Array.isArray(r.json)) deny('gitea-failed');
|
||||
if (r.json.length !== 0 || (r.total !== null && r.total !== 0)) deny('grant-admin-not-dedicated');
|
||||
return typeof t.json?.name === 'string' ? t.json.name : null;
|
||||
}
|
||||
|
||||
// One token file's text, read with Credentials' checks: no symlink, a regular file the stack's user owns,
|
||||
// 0600, at most 16 KiB, then the token format. null if `missing` and there is no file.
|
||||
function readSecretFile(file, { missing = false } = {}) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
} catch (e) {
|
||||
if (missing && e.code === 'ENOENT') return null;
|
||||
deny('credential-file');
|
||||
}
|
||||
try {
|
||||
const s = fstatSync(fd);
|
||||
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16384) deny('credential-file');
|
||||
return readFileSync(fd, 'utf8');
|
||||
} catch (e) {
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('credential-file');
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
export function checkToken(token) {
|
||||
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
|
||||
const t = token.replace(/\n$/, '');
|
||||
if (t.length < 16) deny('credential-format');
|
||||
return t;
|
||||
}
|
||||
function writeFileAtomic(dir, name, text) {
|
||||
const file = join(dir, name);
|
||||
const tmp = file + '.tmp';
|
||||
try {
|
||||
unlinkSync(tmp);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, text);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(dir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
}
|
||||
|
||||
// The trusted Gitea half. `ca` is a PEM the HTTPS calls trust instead of the system's, for the tests'
|
||||
// fake Gitea; production passes none. `send` is replaceable only for the tests' failure rows.
|
||||
export function giteaGrant({ configDir, ca = undefined, timeoutMs = CALL_MS, transport = send }) {
|
||||
const grantsRoot = join(configDir, 'secrets', 'grants');
|
||||
const slotDir = (business) => join(grantsRoot, business, 'gitea');
|
||||
const slotDirs = (business) => [grantsRoot, join(grantsRoot, business), slotDir(business)];
|
||||
// Section 3c: the slot's metadata, or null when there is no slot. A slot dir or file that isn't the
|
||||
// stack user's own and private refuses credential-file, as does metadata of any other shape.
|
||||
const adminMeta = (business) => {
|
||||
for (const dir of slotDirs(business)) if (!privateDir(dir, { missing: true })) return null;
|
||||
const text = readSecretFile(join(slotDir(business), 'admin.json'), { missing: true });
|
||||
if (text === null) return null;
|
||||
let meta;
|
||||
try {
|
||||
meta = JSON.parse(text);
|
||||
} catch {
|
||||
deny('credential-file');
|
||||
}
|
||||
if (
|
||||
!meta || typeof meta !== 'object' || Array.isArray(meta) ||
|
||||
Object.keys(meta).sort().join() !== [...META_KEYS].sort().join() ||
|
||||
!parseGiteaUrl(meta.url).url || !giteaName(meta.account) || !day(meta.rotateBy) ||
|
||||
typeof meta.setAt !== 'string' || !AT.test(meta.setAt)
|
||||
)
|
||||
deny('credential-file');
|
||||
return { url: meta.url, account: meta.account, rotateBy: meta.rotateBy, setAt: meta.setAt };
|
||||
};
|
||||
const adminToken = (business) => {
|
||||
for (const dir of slotDirs(business)) privateDir(dir);
|
||||
return checkToken(readSecretFile(join(slotDir(business), 'admin')));
|
||||
};
|
||||
// The admin api over one token. Basic on the token routes (F1), the `token` header elsewhere. Every
|
||||
// call goes through log(op, run, after).
|
||||
const adminApi = (url, account, token, log) => {
|
||||
const header = { token };
|
||||
const basic = { basic: [account, token] };
|
||||
const go = (op, method, path, auth, body, after) => log(op, () => transport({ url, method, path, auth, body, ca, timeoutMs }), after);
|
||||
return {
|
||||
version: () => go('version', 'GET', '/api/v1/version', header),
|
||||
self: () => go('self', 'GET', '/api/v1/user', header),
|
||||
tokenScopes: () => go('token-scopes', 'GET', '/api/v1/token', header),
|
||||
reposCheck: () => go('repos-check', 'GET', '/api/v1/user/repos?limit=1&page=1', header),
|
||||
userGet: (user) => go('user-get', 'GET', `/api/v1/users/${segment(user)}`, header),
|
||||
userCreate: (body, after) => go('user-create', 'POST', '/api/v1/admin/users', header, body, after),
|
||||
userPatch: (user, body) => go('user-patch', 'PATCH', `/api/v1/admin/users/${segment(user)}`, header, body),
|
||||
tokenList: (user, page) => go('token-list', 'GET', `/api/v1/users/${segment(user)}/tokens?limit=50&page=${page}`, basic),
|
||||
tokenCreate: (user, name, scopes = SEAT_SCOPES, op = 'token-create') =>
|
||||
go(op, 'POST', `/api/v1/users/${segment(user)}/tokens`, basic, { name, scopes: [...scopes] }),
|
||||
tokenDelete: (user, idOrName) => go('token-delete', 'DELETE', `/api/v1/users/${segment(user)}/tokens/${segment(idOrName)}`, basic),
|
||||
};
|
||||
};
|
||||
return {
|
||||
giteaGrantCheck,
|
||||
identitySha256,
|
||||
adminSha256,
|
||||
definitionSha256,
|
||||
tokenName,
|
||||
adminMeta,
|
||||
openGrants: openGiteaGrants,
|
||||
tokenUnknown,
|
||||
checkToken,
|
||||
forkUrl,
|
||||
repoPage: REPO_PAGE,
|
||||
repoPages: REPO_PAGES,
|
||||
// The admin api over the slot's token, for a grant, an undo, a revoke or a sweep. Refuses
|
||||
// grant-admin-missing with no slot.
|
||||
admin(business, log) {
|
||||
const meta = adminMeta(business);
|
||||
if (!meta) deny('grant-admin-missing');
|
||||
return { meta, api: adminApi(meta.url, meta.account, adminToken(business), log) };
|
||||
},
|
||||
adminChecks,
|
||||
// The seat token's calls, made with the minted token (steps 4 and 5) or the store file's (settle).
|
||||
seat(url, token, log) {
|
||||
const header = { token };
|
||||
const go = (op, method, path, body) => log(op, () => transport({ url, method, path, auth: header, body, ca, timeoutMs }));
|
||||
return {
|
||||
self: () => go('seat-self', 'GET', '/api/v1/user'),
|
||||
repos: (page) => go('seat-repos', 'GET', `/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`),
|
||||
forkGet: (owner, repo) => go('fork-get', 'GET', `/api/v1/repos/${segment(owner)}/${segment(repo)}`),
|
||||
fork: (owner, repo) => go('fork', 'POST', `/api/v1/repos/${segment(owner)}/${segment(repo)}/forks`, {}),
|
||||
whoami: () => go('token-whoami', 'GET', '/api/v1/token'),
|
||||
};
|
||||
},
|
||||
// Section 3e: `set` checks C1 to C4 with the given token before it writes anything. A slot already
|
||||
// there refuses grant-admin-exists, whatever its url (remove first). Returns the metadata written.
|
||||
async setAdmin(business, { url, account, rotateBy, token, setAt }, log) {
|
||||
const t = checkToken(token);
|
||||
if (adminMeta(business)) deny('grant-admin-exists');
|
||||
await adminChecks(adminApi(url, account, t, log), account);
|
||||
if (adminMeta(business)) deny('grant-admin-exists');
|
||||
const meta = { url, account, rotateBy, setAt };
|
||||
this.writeAdmin(business, t, meta);
|
||||
return meta;
|
||||
},
|
||||
// The slot's write: the token, then admin.json, each tmp, fsync, rename. Dirs are made 0700.
|
||||
writeAdmin(business, token, meta) {
|
||||
mkdirSync(join(configDir, 'secrets'), { recursive: true, mode: 0o700 });
|
||||
for (const dir of slotDirs(business)) {
|
||||
if (!privateDir(dir, { missing: true })) mkdirSync(dir, { mode: 0o700 });
|
||||
privateDir(dir);
|
||||
}
|
||||
writeFileAtomic(slotDir(business), 'admin', checkToken(token) + '\n');
|
||||
writeFileAtomic(slotDir(business), 'admin.json', JSON.stringify(meta) + '\n');
|
||||
},
|
||||
// Section 3d rotate: C1 to C4, the successor minted for the admin account itself and named by the
|
||||
// UTC second, C2 and C3 with it, then the write. The old token is deleted by name with the new one;
|
||||
// `left` names it if that fails. A name already taken (two rotations in one second) refuses before
|
||||
// anything is written. `stamp` is yyyymmddThhmmssZ.
|
||||
async rotateAdmin(business, { rotateBy, setAt, stamp }, log) {
|
||||
const { meta, api } = this.admin(business, log);
|
||||
const oldName = await adminChecks(api, meta.account);
|
||||
const name = `mosaic-grant-${stamp}`;
|
||||
if (name === oldName) deny('grant-admin-name-taken');
|
||||
const r = await api.tokenCreate(meta.account, name, ADMIN_SCOPES, 'admin-rotate');
|
||||
if (r.status === 400 || r.status === 422) deny('grant-admin-name-taken');
|
||||
if (r.status !== 201 || typeof r.json?.sha1 !== 'string') deny('gitea-failed');
|
||||
const successor = checkToken(r.json.sha1);
|
||||
const next = adminApi(meta.url, meta.account, successor, log);
|
||||
const self = await next.self();
|
||||
if (self.status !== 200 || self.json?.login !== meta.account || self.json?.is_admin !== true) deny('grant-admin-account');
|
||||
const t = await next.tokenScopes();
|
||||
if (t.status !== 200 || !Array.isArray(t.json?.scopes) || [...t.json.scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
|
||||
const written = { url: meta.url, account: meta.account, rotateBy, setAt };
|
||||
this.writeAdmin(business, successor, written);
|
||||
let left = null;
|
||||
if (oldName === null) left = 'unknown';
|
||||
else {
|
||||
const d = await next.tokenDelete(meta.account, oldName);
|
||||
if (d.status !== 204 && d.status !== 404) left = oldName;
|
||||
}
|
||||
return { meta: written, tokenName: name, adminTokenLeft: left };
|
||||
},
|
||||
// rotate --finish: the old token by name, with the slot's token.
|
||||
async finishRotate(business, oldName, log) {
|
||||
const { meta, api } = this.admin(business, log);
|
||||
await adminChecks(api, meta.account);
|
||||
const d = await api.tokenDelete(meta.account, oldName);
|
||||
if (d.status !== 204 && d.status !== 404) deny('gitea-failed');
|
||||
},
|
||||
// Section 3d remove: the slot's files and dir go. No network call.
|
||||
removeAdmin(business) {
|
||||
if (!adminMeta(business)) deny('grant-admin-missing');
|
||||
for (const name of ['admin', 'admin.json', 'admin.tmp', 'admin.json.tmp'])
|
||||
try {
|
||||
unlinkSync(join(slotDir(business), name));
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
try {
|
||||
rmdirSync(slotDir(business));
|
||||
} catch {
|
||||
deny('credential-file');
|
||||
}
|
||||
},
|
||||
// Step 2: the never-stored password of a new user and the nonce in its full_name.
|
||||
password: () => randomBytes(24).toString('base64url'),
|
||||
nonce: () => randomBytes(8).toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
// The store reference for an agent's gitea token, as Credentials checks it.
|
||||
export const giteaReference = (storeRoot, business, agent, rotateBy) => ({
|
||||
store: true,
|
||||
file: storeRoot ? join(storeRoot, business, agent, 'gitea') : null,
|
||||
rotateBy,
|
||||
});
|
||||
|
||||
// The grant executor's calls on Credentials, keyed by (business, agent). The broker gets this and never
|
||||
// Credentials itself, so it still has no use(); useFile hands the file's token only to a settle call.
|
||||
export function giteaKeys({ credentials, storeRoot }) {
|
||||
const at = (business, agent) => business + '/' + agent;
|
||||
return {
|
||||
claim: (business, agent) => credentials.claim(at(business, agent), 'gitea'),
|
||||
release: (business, agent, signal) => credentials.release(at(business, agent), 'gitea', signal),
|
||||
abortClaim: (business, agent) => credentials.abortClaim(at(business, agent), 'gitea'),
|
||||
known: (business, agent) => credentials.status().some((e) => e.instance === at(business, agent) && e.service === 'gitea'),
|
||||
register: (business, agent, rotateBy) => credentials.register(at(business, agent), 'gitea', giteaReference(storeRoot, business, agent, rotateBy)),
|
||||
unregister: (business, agent) => credentials.unregister(at(business, agent), 'gitea'),
|
||||
issue: (business, agent, token, rotateBy, commit) => credentials.issue(at(business, agent), 'gitea', token, rotateBy, commit),
|
||||
revoke: (business, agent) => credentials.revoke(at(business, agent), 'gitea'),
|
||||
reload: (business, agent, rotateBy) => credentials.reload(at(business, agent), 'gitea', rotateBy),
|
||||
hold: (business, agent) => credentials.hold(at(business, agent), 'gitea'),
|
||||
held: (business, agent) => credentials.held(at(business, agent), 'gitea'),
|
||||
useFile: (business, agent, fn) => credentials.useFile(at(business, agent), 'gitea', fn),
|
||||
unlinkStore: (business, agent) => credentials.unlinkStore(at(business, agent), 'gitea'),
|
||||
};
|
||||
}
|
||||
|
||||
// Section 5, before Credentials loads, read-only on the store: each gitea_grants record gives its agent
|
||||
// a store reference to gitea, and `held` lists the pairs whose file memory must not load. Those are an
|
||||
// agent whose last gitea grant.failed is grant-token-unknown with no grant.revoked after it, and an
|
||||
// agent with an open first grant whose last step is commit: its file may hold a token no record backs,
|
||||
// and the broker's reconcile unlinks it. Only grant evidence counts (duckworth-0067 N1). `references`
|
||||
// is changed in place: a record's reference replaces a business-file gitea reference, which names the
|
||||
// same store path or a file the record supersedes.
|
||||
export function giteaStart(store, storeRoot, businesses, references) {
|
||||
const held = [];
|
||||
for (const g of store.all('SELECT business, agent, rotate_by FROM gitea_grants ORDER BY business, agent')) {
|
||||
if (!Object.hasOwn(businesses, g.business)) continue;
|
||||
const refs = (references[g.business + '/' + g.agent] = { ...(references[g.business + '/' + g.agent] ?? {}) });
|
||||
refs.gitea = giteaReference(storeRoot, g.business, g.agent, g.rotate_by);
|
||||
}
|
||||
for (const business of Object.keys(businesses)) {
|
||||
for (const agent of tokenUnknown(store, business).keys()) held.push(business + '/' + agent + ':gitea');
|
||||
for (const o of openGiteaGrants(store, business))
|
||||
if (o.step?.step === 'commit' && o.step.previousTokenId === null) held.push(business + '/' + o.agent + ':gitea');
|
||||
}
|
||||
return { held: [...new Set(held)] };
|
||||
}
|
||||
|
||||
const evidence = (store, business, where, ...args) =>
|
||||
store.all(`SELECT seq, body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND ${where} ORDER BY seq`, business, ...args)
|
||||
.map((e) => ({ seq: e.seq, body: JSON.parse(e.body) }));
|
||||
|
||||
// agent -> {decision, seq}: the agents whose last gitea grant.failed is grant-token-unknown with no gitea
|
||||
// grant.revoked after it (section 5's held row).
|
||||
export function tokenUnknown(store, business) {
|
||||
const last = new Map();
|
||||
for (const e of evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action') IN ('grant.failed','grant.revoked')"))
|
||||
if (e.body.action === 'grant.revoked') last.delete(e.body.agent);
|
||||
else if (e.body.code === 'grant-token-unknown') last.set(e.body.agent, { decision: e.body.grantDecision, seq: e.seq });
|
||||
return last;
|
||||
}
|
||||
|
||||
// Every gitea grant spent with no grant.applied or grant.failed after its spend: {decision, agent, step,
|
||||
// seq, revokedAfter}. step is the last grant.step body or null, seq that step's or the spend's, and
|
||||
// revokedAfter says a gitea grant.revoked of the agent came after it (N9). A step 0 grant.failed comes
|
||||
// before the spend, so only ends after it count.
|
||||
export function openGiteaGrants(store, business) {
|
||||
const out = [];
|
||||
for (const spend of evidence(store, business, "json_extract(body,'$.operation')='grant.gitea'")) {
|
||||
const decision = spend.body.decision;
|
||||
const mine = evidence(store, business, "json_extract(body,'$.grantDecision')=? AND seq > ?", decision, spend.seq);
|
||||
if (mine.some((e) => e.body.action === 'grant.applied' || e.body.action === 'grant.failed')) continue;
|
||||
const last = mine.filter((e) => e.body.action === 'grant.step').at(-1) ?? null;
|
||||
out.push({ decision, agent: last?.body.agent ?? null, step: last?.body ?? null, seq: last?.seq ?? spend.seq });
|
||||
}
|
||||
for (const o of out)
|
||||
o.revokedAfter =
|
||||
o.agent !== null &&
|
||||
evidence(store, business, "json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.agent')=? AND seq > ?", o.agent, o.seq)
|
||||
.length > 0;
|
||||
return out;
|
||||
}
|
||||
@@ -25,8 +25,9 @@ try {
|
||||
const client = new Client({
|
||||
path: process.argv[2],
|
||||
// Row 254: library.sync fetches for up to 100 s and the server holds its socket for 120 s,
|
||||
// so wait past that for the server's own answer. Every other verb keeps the 5 s default.
|
||||
timeout: request.verb === 'library.sync' ? 130_000 : undefined,
|
||||
// so wait past that for the server's own answer. Row 236: the grants verbs call Gitea and get the
|
||||
// same wait. Every other verb keeps the 5 s default.
|
||||
timeout: request.verb === 'library.sync' || request.verb.startsWith('grants.') ? 130_000 : undefined,
|
||||
human: {
|
||||
business: request.business,
|
||||
pid: me.pid,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Row 236 (#436b, #1742, design r5 section 7): the Gitea users this stack made and the Gitea grants
|
||||
-- live in the store. The grant executor, revoke and sweep are the only writers, each in the
|
||||
-- transaction of its event. Only new tables; the grant event trigger from 3j covers their events.
|
||||
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;
|
||||
@@ -18,6 +18,7 @@ import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { webService } from './web.mjs';
|
||||
import { hostExecutor, sweepRunDir } from './host-run.mjs';
|
||||
import { grantKeys, hostGrant, hostReference, sweepHostKeys } from './host-grant.mjs';
|
||||
import { giteaGrant, giteaKeys, giteaStart } from './gitea-grant.mjs';
|
||||
import { verifyPrincipal } from './principal.mjs';
|
||||
import { inspectInterpreter, recordInterpreterStart } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
@@ -120,7 +121,10 @@ export async function startBroker({
|
||||
// Row 235 (r6 section 6, N1): a host.<id> key file with no record goes before Credentials loads.
|
||||
for (const entry of sweepHostKeys(storeRoot, Object.keys(businesses), granted))
|
||||
process.stderr.write(`hosts: ${JSON.stringify({ event: 'host-key-swept', ...entry })}\n`);
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot });
|
||||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
||||
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held });
|
||||
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
|
||||
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
|
||||
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
|
||||
@@ -154,6 +158,9 @@ export async function startBroker({
|
||||
hostGrant: runRoot ? hostGrant({ configDir: organization.dir, runRoot }) : null,
|
||||
// Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use().
|
||||
hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null,
|
||||
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
|
||||
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
|
||||
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -260,6 +267,8 @@ export async function startBroker({
|
||||
// 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.
|
||||
broker.reconcileHosts();
|
||||
// Row 236 (#436b r5 section 5): the Gitea grants' remote undo, settle and open revokes, likewise.
|
||||
broker.reconcileGitea();
|
||||
// S6 launcher operations. The host decides capacity and the instance list; the broker
|
||||
// authorizes role.launch and keeps the evidence, refusals included.
|
||||
// A refusal is recorded against the caller, as request() records one.
|
||||
|
||||
@@ -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, 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 } from './broker.mjs';
|
||||
import { inspectInterpreter } from './interpreter.mjs';
|
||||
import { createPeerReader } from './peercred.mjs';
|
||||
const LIMIT = 65536;
|
||||
@@ -139,6 +139,8 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
r.verb === 'spec.read' || r.verb === 'record.write' || PROJECT_FILE_VERBS.includes(r.verb) || LIBRARY_VERBS.includes(r.verb) ||
|
||||
// Row 234 (#1740): a grant raise awaits its keyscan, and hosts.check its ssh.
|
||||
HOSTS_VERBS.includes(r.verb) ||
|
||||
// Row 236 (#1742): the grants verbs may call Gitea several times, 15 s each at most.
|
||||
GRANTS_VERBS.includes(r.verb) ||
|
||||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit') ||
|
||||
Object.hasOwn(r.args ?? {}, 'grant')))) {
|
||||
const own = transient;
|
||||
|
||||
@@ -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 = '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c';
|
||||
export const SCHEMA_VERSION = '3j';
|
||||
export const SCHEMA_SHA256 = 'aba9240825a35fdcc741439d0708efa33afcf519d11cac9952c7207ad99250f0';
|
||||
export const SCHEMA_VERSION = '3k';
|
||||
// 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';
|
||||
@@ -61,6 +61,11 @@ const migrations = [{
|
||||
from: '3i',
|
||||
to: '3j',
|
||||
sql: readFileSync(new URL('./migrations/3i-3j.sql', import.meta.url), 'utf8'),
|
||||
}, {
|
||||
// Row 236 (#1742, #436b section 7): the Gitea users the stack made and the Gitea grants. Only new tables.
|
||||
from: '3j',
|
||||
to: '3k',
|
||||
sql: readFileSync(new URL('./migrations/3j-3k.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
|
||||
@@ -70,6 +70,7 @@ 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/3h-3i.sql'), step(readFileSync(here('../src/migrations/3h-3i.sql'), 'utf8')));
|
||||
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'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
|
||||
@@ -77,7 +78,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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
// Every table's rows as they were, usage_records with its seq values and sqlite_sequence with 50.
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(before.rows.usage_records.map(r => r.seq), [1, 2, 3]);
|
||||
@@ -86,7 +87,9 @@ test('REQ-LIMIT-4 3h copy migrates to 3i: every row keeps its seq, the sequence
|
||||
const old = ' account TEXT CHECK (account IS NULL),\n';
|
||||
assert.equal(was.filter(r => r.sql?.includes(old)).map(r => r.name).join(), 'usage_records');
|
||||
// Row 234 (#1740) adds 3i→3j's own objects after this step. Its test is grant-migration.test.mjs.
|
||||
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
|
||||
// Row 236 (#1742) adds 3j→3k's; its test is gitea-migration.test.mjs.
|
||||
const later = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1',
|
||||
'gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
|
||||
assert.deepEqual(master(path).filter(r => !later.includes(r.name)), was.map(r => r.name === 'usage_records' ? { ...r, sql: r.sql.replace(old, ' account TEXT,\n') } : r));
|
||||
assert.equal(was.filter(r => r.type === 'trigger' && r.tbl_name === 'usage_records').length, 3);
|
||||
const store = new Store(root);
|
||||
@@ -125,7 +128,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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
});
|
||||
|
||||
@@ -137,14 +140,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 = '3j';", "export const SCHEMA_VERSION = '3h';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(snapshot(path, after.columns).rows, after.rows);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// and the ban on channel plug-ins importing a proof caller are retained.
|
||||
// Row 254 (#1759) re-pins human-cli.mjs for the 130 s library.sync client timeout and webui
|
||||
// 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.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -14,7 +16,7 @@ import { Client } from "../src/client.mjs";
|
||||
|
||||
const baselines = {
|
||||
"packages/bus/src/human.mjs": "0c6d96abe4607cba0b02b0638a2fc217ff8135bac9a47761a0ef015fbe9d3079",
|
||||
"packages/bus/src/human-cli.mjs": "9c9af66b98ea0a490c9a880fc81c3c62414a8ee0b5ba6478608771b8c4f87932",
|
||||
"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,
|
||||
// organization-unknown-instance, organization-credentials-out-of-scope, organization-launch-not-configured,
|
||||
|
||||
@@ -606,7 +606,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 = '3j';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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');
|
||||
@@ -630,7 +630,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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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 = '3j';", "export const SCHEMA_VERSION = '3k';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3j', to: '3k', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "export const SCHEMA_VERSION = '3l';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3k', to: '3l', 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'])
|
||||
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'])
|
||||
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, '3k');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3l');
|
||||
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 3j ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3k ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// Row 236 (#1742, #436b r5 section 9): a fake Gitea API for the grant executor, on 127.0.0.1 over TLS
|
||||
// with row 199's scratch CA. It keeps users, their tokens and repositories in memory and answers the
|
||||
// routes gitea-grant.mjs calls, with Gitea's auth rules as section 1 found them: the token routes take
|
||||
// Basic auth as the account itself or a site admin, the rest the `token` header. Every token is a fake
|
||||
// made here. The request log holds the method, the path, the auth kind and the login, never a token.
|
||||
// `rule(match, answer)` makes one route answer otherwise: a status and body, `hang` (no answer, so the
|
||||
// caller's timeout runs) or `drop` (the socket closes). Nothing here leaves 127.0.0.1.
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createServer } from 'node:https';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const fakeToken = () => randomBytes(20).toString('hex');
|
||||
|
||||
export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic-grant', adminScopes = ['write:admin', 'write:repository', 'write:user'] } = {}) {
|
||||
const tls = scratchCa(dir);
|
||||
let nextId = 1;
|
||||
const users = new Map();
|
||||
const repos = [];
|
||||
const rules = [];
|
||||
const requests = [];
|
||||
const state = { version };
|
||||
const lower = (x) => String(x).toLowerCase();
|
||||
const userJson = (u) => ({ id: u.id, login: u.login, email: u.email, full_name: u.full_name, is_admin: u.is_admin, prohibit_login: u.prohibit_login });
|
||||
const repoJson = (r) => ({
|
||||
id: r.id, name: r.name, full_name: `${r.owner}/${r.name}`, owner: { login: r.owner }, fork: Boolean(r.parent),
|
||||
parent: r.parent ? { full_name: r.parent } : null,
|
||||
});
|
||||
const api = {
|
||||
url: null,
|
||||
ca: readFileSync(tls.ca, 'utf8'),
|
||||
state,
|
||||
requests,
|
||||
users,
|
||||
repos,
|
||||
// Every token value this fake made, for the tests' leak checks. Never printed.
|
||||
issued: [],
|
||||
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
|
||||
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
|
||||
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
|
||||
users.set(lower(login), u);
|
||||
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
|
||||
},
|
||||
addToken(login, name, scopes) {
|
||||
const u = users.get(lower(login));
|
||||
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
|
||||
u.tokens.push(tok);
|
||||
api.issued.push(tok.sha1);
|
||||
return tok;
|
||||
},
|
||||
removeUser(login) {
|
||||
users.delete(lower(login));
|
||||
},
|
||||
addRepo(owner, name, { parent = null, collaborators = [] } = {}) {
|
||||
const r = { id: nextId++, owner, name, parent, collaborators: collaborators.map(lower) };
|
||||
repos.push(r);
|
||||
return r;
|
||||
},
|
||||
tokens: (login) => users.get(lower(login))?.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes })) ?? null,
|
||||
rule(match, answer) {
|
||||
const r = { match, answer };
|
||||
rules.push(r);
|
||||
return () => rules.splice(rules.indexOf(r), 1);
|
||||
},
|
||||
// The ops of the log, as "METHOD path" lines without the query.
|
||||
calls: () => requests.map((r) => `${r.method} ${r.path.split('?')[0]}`),
|
||||
};
|
||||
const adminUser = api.addUser(admin, { is_admin: true, scopes: adminScopes, tokenName: 'mosaic-grant-bootstrap' });
|
||||
api.admin = adminUser.user;
|
||||
api.adminToken = adminUser.token.sha1;
|
||||
|
||||
const byToken = (sha1) => {
|
||||
for (const u of users.values()) {
|
||||
const tok = u.tokens.find((x) => x.sha1 === sha1);
|
||||
if (tok) return { user: u, token: tok };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const authOf = (req) => {
|
||||
const h = req.headers.authorization ?? '';
|
||||
if (h.startsWith('token ')) return { kind: 'token', who: byToken(h.slice(6)) };
|
||||
if (h.startsWith('Basic ')) {
|
||||
const [login, ...rest] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
|
||||
const who = byToken(rest.join(':'));
|
||||
return { kind: 'basic', who: who && lower(who.user.login) === lower(login) ? who : null };
|
||||
}
|
||||
return { kind: 'none', who: null };
|
||||
};
|
||||
const reachable = (u) => repos.filter((r) => lower(r.owner) === lower(u.login) || r.collaborators.includes(lower(u.login)));
|
||||
|
||||
function route(method, path, query, auth, body) {
|
||||
const who = auth.who;
|
||||
const p = path.split('/').slice(3).map(decodeURIComponent);
|
||||
if (method === 'GET' && path === '/api/v1/version') return [200, { version: state.version }];
|
||||
if (!who) return [401, { message: 'unauthorized' }];
|
||||
const me = who.user;
|
||||
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
|
||||
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
|
||||
if (method === 'GET' && path === '/api/v1/user/repos') {
|
||||
const all = reachable(me);
|
||||
const limit = Number(query.get('limit') ?? 30);
|
||||
const page = Number(query.get('page') ?? 1);
|
||||
return [200, all.slice((page - 1) * limit, page * limit).map(repoJson), all.length];
|
||||
}
|
||||
if (p[0] === 'users' && p.length === 2 && method === 'GET') {
|
||||
const u = users.get(lower(p[1]));
|
||||
return u ? [200, userJson(u)] : [404, { message: 'user does not exist' }];
|
||||
}
|
||||
if (p[0] === 'admin' && p[1] === 'users') {
|
||||
if (!me.is_admin || !who.token.scopes.includes('write:admin')) return [403, { message: 'forbidden' }];
|
||||
if (method === 'POST' && p.length === 2) {
|
||||
if (users.has(lower(body.username))) return [422, { message: 'user already exists' }];
|
||||
const u = { id: nextId++, login: body.username, email: body.email, full_name: body.full_name, is_admin: false, prohibit_login: false, tokens: [] };
|
||||
users.set(lower(u.login), u);
|
||||
return [201, userJson(u)];
|
||||
}
|
||||
if (method === 'PATCH' && p.length === 3) {
|
||||
const u = users.get(lower(p[2]));
|
||||
if (!u) return [404, { message: 'user does not exist' }];
|
||||
if (typeof body.prohibit_login === 'boolean') u.prohibit_login = body.prohibit_login;
|
||||
return [200, userJson(u)];
|
||||
}
|
||||
}
|
||||
if (p[0] === 'users' && p[2] === 'tokens') {
|
||||
if (auth.kind !== 'basic') return [401, { message: 'basic auth required' }];
|
||||
const u = users.get(lower(p[1]));
|
||||
if (lower(me.login) !== lower(p[1]) && !me.is_admin) return [403, { message: 'forbidden' }];
|
||||
if (!u) return [404, { message: 'user does not exist' }];
|
||||
if (method === 'GET' && p.length === 3) {
|
||||
const limit = Number(query.get('limit') ?? 30);
|
||||
const page = Number(query.get('page') ?? 1);
|
||||
const list = u.tokens.map((x) => ({ id: x.id, name: x.name, scopes: x.scopes }));
|
||||
return [200, list.slice((page - 1) * limit, page * limit), list.length];
|
||||
}
|
||||
if (method === 'POST' && p.length === 3) {
|
||||
if (u.tokens.some((x) => x.name === body.name)) return [400, { message: 'access token name has been used already' }];
|
||||
const tok = api.addToken(u.login, body.name, body.scopes);
|
||||
return [201, { id: tok.id, name: tok.name, sha1: tok.sha1, scopes: tok.scopes }];
|
||||
}
|
||||
if (method === 'DELETE' && p.length === 4) {
|
||||
const i = u.tokens.findIndex((x) => String(x.id) === p[3] || x.name === p[3]);
|
||||
if (i < 0) return [404, { message: 'token not found' }];
|
||||
u.tokens.splice(i, 1);
|
||||
return [204, null];
|
||||
}
|
||||
}
|
||||
if (p[0] === 'repos' && p.length === 3 && method === 'GET') {
|
||||
const r = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
|
||||
return r ? [200, repoJson(r)] : [404, { message: 'not found' }];
|
||||
}
|
||||
if (p[0] === 'repos' && p.length === 4 && p[3] === 'forks' && method === 'POST') {
|
||||
const up = repos.find((x) => lower(x.owner) === lower(p[1]) && lower(x.name) === lower(p[2]));
|
||||
if (!up) return [404, { message: 'not found' }];
|
||||
if (repos.some((x) => lower(x.owner) === lower(me.login) && lower(x.name) === lower(up.name))) return [409, { message: 'exists' }];
|
||||
return [202, repoJson(api.addRepo(me.login, up.name, { parent: `${up.owner}/${up.name}` }))];
|
||||
}
|
||||
return [404, { message: 'no route' }];
|
||||
}
|
||||
|
||||
const server = createServer({ key: tls.key, cert: tls.cert }, (req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', async () => {
|
||||
const u = new URL(req.url, 'https://127.0.0.1');
|
||||
const auth = authOf(req);
|
||||
let body = null;
|
||||
try {
|
||||
body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
|
||||
} catch {}
|
||||
const entry = { method: req.method, path: u.pathname + u.search, auth: auth.kind, as: auth.who?.user.login ?? null };
|
||||
requests.push(entry);
|
||||
const rule = rules.find((r) => r.match(entry, body));
|
||||
// An answer function may be async, to hold a call open until the test lets it go.
|
||||
const answer = rule ? (typeof rule.answer === 'function' ? await rule.answer(entry, body) : rule.answer) : null;
|
||||
if (answer?.hang) return;
|
||||
if (answer?.drop) return req.socket.destroy();
|
||||
const [status, json, total] = answer && !answer.pass ? [answer.status, answer.json ?? null, answer.total] : route(req.method, u.pathname, u.searchParams, auth, body ?? {});
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
if (total !== undefined && total !== null) headers['x-total-count'] = String(total);
|
||||
res.writeHead(status, headers);
|
||||
res.end(json === null || status === 204 ? undefined : JSON.stringify(json));
|
||||
});
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
t.after(() => new Promise((r) => {
|
||||
server.closeAllConnections?.();
|
||||
server.close(() => r());
|
||||
}));
|
||||
api.url = `https://127.0.0.1:${server.address().port}`;
|
||||
return api;
|
||||
}
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
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 thread_claims (
|
||||
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 ('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)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
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 thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread 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 thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); 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 thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread 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 thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); 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 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 delivery_same_thread 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 thread = t.id)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''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 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;
|
||||
@@ -0,0 +1,200 @@
|
||||
// Row 236 (#1742, #436b r5 section 9, X-G3): the grant path end to end on a scratch gitea/gitea:1.27.1, as
|
||||
// section 1's facts were found: a throwaway container on 127.0.0.1 over TLS with row 199's scratch CA, its
|
||||
// data inside the container, removed after. The test never pulls an image; without Docker or the image it
|
||||
// skips. The admin and seat tokens are scratch tokens this container made; none is printed, and no test
|
||||
// here reaches git.mosaicstack.dev or any provider.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { request } from 'node:https';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { SEAT_SCOPES, giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const IMAGE = 'gitea/gitea:1.27.1';
|
||||
const USER = 'demo-coder';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
||||
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
||||
|
||||
const freePort = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
// One HTTPS call to the scratch container, trusting only the scratch CA.
|
||||
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
||||
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
resolve({ status: res.statusCode, json });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0, json: null }));
|
||||
req.end(data ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('X-G3: on scratch 1.27.1, a grant makes the user, mints, forks, checks reach, writes the file, and revoke deletes the token', { skip, timeout: 300000 }, async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'g236-docker-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tlsDir = join(dir, 'tls');
|
||||
const tls = scratchCa(tlsDir);
|
||||
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
||||
chmodSync(tlsDir, 0o755);
|
||||
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
||||
const ca = readFileSync(tls.ca, 'utf8');
|
||||
const port = await freePort();
|
||||
const url = `https://127.0.0.1:${port}`;
|
||||
const name = 'g236-' + randomBytes(6).toString('hex');
|
||||
t.after(() => docker('rm', '-f', name));
|
||||
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
||||
'-e', 'GITEA__security__INSTALL_LOCK=true',
|
||||
'-e', 'GITEA__database__DB_TYPE=sqlite3',
|
||||
'-e', 'GITEA__service__DISABLE_REGISTRATION=true',
|
||||
'-e', 'GITEA__server__PROTOCOL=https',
|
||||
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem',
|
||||
'-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
||||
'-e', `GITEA__server__ROOT_URL=${url}/`,
|
||||
IMAGE);
|
||||
assert.equal(started.status, 0, started.stderr);
|
||||
const end = Date.now() + 120000;
|
||||
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
||||
if (Date.now() > end) assert.fail('the scratch Gitea did not answer in 120 s: ' + docker('logs', '--tail', '20', name).stdout);
|
||||
await sleep(500);
|
||||
}
|
||||
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
||||
const password = () => randomBytes(18).toString('base64url');
|
||||
for (const [user, admin] of [['mosaic-grant', true], ['acme', false]]) {
|
||||
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`, '--password', password(), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
}
|
||||
const minted = gitea('admin', 'user', 'generate-access-token', '--username', 'mosaic-grant', '--token-name', 'mosaic-grant-bootstrap',
|
||||
'--scopes', 'write:admin,write:repository,write:user', '--raw');
|
||||
assert.equal(minted.status, 0, 'generate-access-token failed');
|
||||
const adminToken = minted.stdout.trim();
|
||||
assert.match(adminToken, /^[0-9a-f]{40}$/, 'the admin token has the shape of a Gitea token');
|
||||
const basic = 'Basic ' + Buffer.from(`mosaic-grant:${adminToken}`).toString('base64');
|
||||
for (const repo of ['app', 'lib']) {
|
||||
const r = await call(url, ca, 'POST', '/api/v1/admin/users/acme/repos', { auth: 'token ' + adminToken, body: { name: repo, auto_init: true } });
|
||||
assert.equal(r.status, 201);
|
||||
}
|
||||
|
||||
const configDir = join(dir, 'cfg');
|
||||
mkdirSync(configDir, { mode: 0o700 });
|
||||
mkdirSync(join(dir, 'data'), { mode: 0o700 });
|
||||
const storeRoot = join(configDir, 'secrets', 'agents');
|
||||
const store = new Store(join(dir, 'data'));
|
||||
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
|
||||
t.after(() => {
|
||||
credentials.close();
|
||||
store.close();
|
||||
});
|
||||
const business = {
|
||||
id: 'demo',
|
||||
human: 'jason',
|
||||
arbiters: { technical: 'pm', delivery: 'pm' },
|
||||
roles: {
|
||||
chief: { definition: 'chief-of-staff', ...none },
|
||||
pm: { definition: 'pm', ...none },
|
||||
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url, user: USER } }, publish: ['app', 'lib'] },
|
||||
},
|
||||
definitions: {
|
||||
'chief-of-staff': { roleVersion: 2, credentials: [] },
|
||||
pm: { roleVersion: 2, credentials: [] },
|
||||
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
|
||||
},
|
||||
projects: { app: { remote: `${url}/acme/app` }, lib: { remote: `${url}/acme/lib` } },
|
||||
vars: {},
|
||||
};
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses: { demo: business },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
giteaGrant: giteaGrant({ configDir, ca, timeoutMs: 20000 }),
|
||||
giteaKeys: giteaKeys({ credentials, storeRoot }),
|
||||
});
|
||||
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
||||
broker.request(chief, { verb: 'role.claim' });
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
|
||||
// C1 to C4 on the real server, then the slot is written.
|
||||
await broker.request(human, { verb: 'grants.admin.set', args: { service: 'gitea', url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: adminToken + '\n' } });
|
||||
const d = await broker.request(chief, {
|
||||
verb: 'decision.raise',
|
||||
args: {
|
||||
action: 'credential.mint', question: 'Grant coder Gitea?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
|
||||
},
|
||||
});
|
||||
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
|
||||
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
|
||||
const ended = () => events().find((e) => e.grantDecision === d.id && ['grant.applied', 'grant.failed'].includes(e.action));
|
||||
const until = Date.now() + 120000;
|
||||
while (!ended() && Date.now() < until) await sleep(100);
|
||||
await broker.giteaIdle();
|
||||
const applied = ended();
|
||||
assert.equal(applied?.action, 'grant.applied', JSON.stringify(applied));
|
||||
|
||||
// The user, made with the nonce, and its id recorded.
|
||||
const nonce = events().find((e) => e.grantDecision === d.id && e.step === 'user').nonce;
|
||||
const u = await call(url, ca, 'GET', `/api/v1/users/${USER}`, { auth: 'token ' + adminToken });
|
||||
assert.equal(u.status, 200);
|
||||
assert.equal(u.json.full_name, `Coder [mosaic ${nonce}]`);
|
||||
assert.equal(u.json.is_admin, false);
|
||||
const row = store.get("SELECT id, nonce FROM gitea_users WHERE business='demo' AND agent='coder'");
|
||||
assert.deepEqual({ ...row }, { id: u.json.id, nonce });
|
||||
// One token, with the two scopes, at the id the record names.
|
||||
const list = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal(list.json.length, 1);
|
||||
assert.deepEqual([...list.json[0].scopes].sort(), [...SEAT_SCOPES]);
|
||||
const grant = store.get("SELECT token_id FROM gitea_grants WHERE business='demo' AND agent='coder'");
|
||||
assert.equal(list.json[0].id, grant.token_id);
|
||||
// The fork, with its parent.
|
||||
const fork = await call(url, ca, 'GET', `/api/v1/repos/${USER}/app`, { auth: 'token ' + adminToken });
|
||||
assert.equal(fork.status, 200);
|
||||
assert.equal(fork.json.parent?.full_name, 'acme/app');
|
||||
// The 0600 file, whose token Gitea knows as the recorded id; the value is compared, never printed.
|
||||
const file = join(storeRoot, 'demo', 'coder', 'gitea');
|
||||
assert.equal(statSync(file).mode & 0o777, 0o600);
|
||||
const who = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + readFileSync(file, 'utf8').trim() });
|
||||
assert.equal(who.status, 200);
|
||||
assert.equal(who.json.id, grant.token_id);
|
||||
|
||||
// Revoke deletes the token at Gitea: gone from the list, the file and the record gone.
|
||||
const out = await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'X-G3' } });
|
||||
assert.deepEqual(out.serviceLeft, []);
|
||||
await broker.giteaIdle();
|
||||
const after = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
|
||||
assert.equal(after.status, 200);
|
||||
assert.deepEqual(after.json, []);
|
||||
assert.equal(existsSync(file), false);
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM gitea_grants WHERE business='demo'").n, 0);
|
||||
// No scratch token reached the store.
|
||||
const text = store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body).join('\n');
|
||||
assert.ok(!text.includes(adminToken), 'the admin token reached the store');
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
// Row 236 (#1742, #436b r5 section 7): the 3j→3k step adds the gitea_users and gitea_grants tables and
|
||||
// nothing else. It runs on a 3j store built here from the pinned 3j schema (02da6a2ae's schema.sql) and
|
||||
// fixture rows. No live store is read or copied.
|
||||
const AT = '2026-10-21T00:00:00.000Z';
|
||||
const ddl3j = readFileSync(new URL('./fixtures/schema-3j.sql', import.meta.url), 'utf8');
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const NEW = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
|
||||
function source3j(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gitea-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(ddl3j).digest('hex'), '0a7b633ce7eec9d0bb37c3255f4244d97bd0c8eac38db91a11b4ef6cc982ec8c');
|
||||
db.exec(ddl3j);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3j');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
run('INSERT INTO events(id,at,business,kind,actor_role,actor_run,body) VALUES(?,?,?,?,?,?,?)', 'e1', AT, 'demo', 'action.allowed', null, 'grant', '{"operation":"grant.host-enroll","decision":"d1"}');
|
||||
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'e2', AT, 'demo', 'action.allowed', '{}');
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
return { root, path };
|
||||
}
|
||||
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')").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(); }
|
||||
}
|
||||
function master(path) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
return db.prepare('SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name').all().map(r => ({ ...r }));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
const version = (path) => {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try { return db.prepare("SELECT value FROM meta WHERE key='schema_version'").get().value; } finally { db.close(); }
|
||||
};
|
||||
// A copy of the bus module whose store.mjs, schema.sql and 3j→3k step the caller edits.
|
||||
async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
const moduleRoot = join(root, name);
|
||||
mkdirSync(join(moduleRoot, 'src/migrations'), { recursive: true });
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8'), edited = store ? store(source) : source;
|
||||
if (store) assert.notEqual(edited, source);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), edited);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), schema ?? readFileSync(here('../schema.sql'), 'utf8'));
|
||||
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'])
|
||||
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')));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'gitea-store-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
store.run(`INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking)
|
||||
VALUES('d1',?,'demo','chief','run1','gated','credential.mint','human','q','["yes","no"]','yes',0)`, AT);
|
||||
return store;
|
||||
}
|
||||
const user = (store, values = {}) => store.run(
|
||||
'INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(:business,:agent,:user,:id,:nonce,:decision)',
|
||||
{ business: 'demo', agent: 'coder', user: 'demo-coder', id: 7, nonce: '0123456789abcdef', decision: 'd1', ...values });
|
||||
const grant = (store, values = {}) => store.run(
|
||||
'INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(:business,:agent,:user,:token,:name,:projects,:rotate,:decision)',
|
||||
{ business: 'demo', agent: 'coder', user: 'demo-coder', token: 9, name: 'mosaic-demo-coder-gitea-d1', projects: '["app"]', rotate: '2026-12-01', decision: 'd1', ...values });
|
||||
|
||||
test('3j copy migrates to 3k: every row kept, and sqlite_master gains only the two tables and their keys', 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), '3k');
|
||||
assert.deepEqual(rows(path), before);
|
||||
const now = master(path);
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
try {
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM gitea_users').n, 0);
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM gitea_grants').n, 0);
|
||||
user(store);
|
||||
grant(store);
|
||||
} finally { store.close(); }
|
||||
});
|
||||
|
||||
test('a 3j→3k step that fails partway leaves the store at 3j with every row and the schema intact', async t => {
|
||||
const { root, path } = source3j(t), before = rows(path), was = master(path);
|
||||
// The real step plus a statement that fails only when events has a row, so the empty replay at
|
||||
// import passes and the store's run fails after both tables exist.
|
||||
const Failing = await engine(root, 'engine-failing', { step: (sql) => sql + 'INSERT INTO events SELECT * FROM events LIMIT 1;\n' });
|
||||
assert.throws(() => new Failing(root, { migrateSchema: true }), /events is append-only/);
|
||||
assert.equal(version(path), '3j');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), before);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
assert.equal(version(path), '3k');
|
||||
});
|
||||
|
||||
test('the 3j engine refuses a 3k store, migrating or not, and leaves it at 3k', async t => {
|
||||
const { root, path } = source3j(t);
|
||||
new Store(root, { migrateSchema: true }).close();
|
||||
const after = rows(path), was = master(path);
|
||||
const Store3j = await engine(root, 'engine-3j', {
|
||||
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 = '3k';", "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), '3k');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
test('gitea_users: one user per business and agent, an id above 0, a 16-hex nonce and a real decision', t => {
|
||||
const store = fresh(t);
|
||||
user(store);
|
||||
assert.throws(() => user(store, { user: 'other' }), /UNIQUE constraint failed: gitea_users\.business, gitea_users\.agent/);
|
||||
user(store, { business: 'other' });
|
||||
for (const nonce of ['0123456789abcde', '0123456789abcdef0', '0123456789ABCDEF', '0123456789abcdeg'])
|
||||
assert.throws(() => user(store, { agent: 'x', nonce }), /CHECK constraint failed/, nonce);
|
||||
assert.throws(() => user(store, { agent: 'x', id: 0 }), /CHECK constraint failed/);
|
||||
assert.throws(() => user(store, { agent: 'x', id: 'seven' }), /cannot store TEXT value in INTEGER column/);
|
||||
assert.throws(() => user(store, { agent: 'x', decision: 'nope' }), /FOREIGN KEY constraint failed/);
|
||||
});
|
||||
|
||||
test('gitea_grants: one grant per agent, a projects array, a token id above 0, and only for a recorded user', t => {
|
||||
const store = fresh(t);
|
||||
assert.throws(() => grant(store), /FOREIGN KEY constraint failed/);
|
||||
user(store);
|
||||
for (const projects of ['[]', '{}', '"app"', 'not json'])
|
||||
assert.throws(() => grant(store, { projects }), /CHECK constraint failed/, projects);
|
||||
assert.throws(() => grant(store, { token: 0 }), /CHECK constraint failed/);
|
||||
assert.throws(() => grant(store, { decision: 'nope' }), /FOREIGN KEY constraint failed/);
|
||||
grant(store);
|
||||
assert.throws(() => grant(store, { token: 10 }), /UNIQUE constraint failed/);
|
||||
assert.throws(() => store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'"), /FOREIGN KEY constraint failed/);
|
||||
store.run("DELETE FROM gitea_grants WHERE business='demo' AND agent='coder'");
|
||||
store.run("DELETE FROM gitea_users WHERE business='demo' AND agent='coder'");
|
||||
});
|
||||
@@ -16,6 +16,8 @@ const TAG = 'mosaic-' + 'a'.repeat(32);
|
||||
const ddl3i = readFileSync(new URL('./fixtures/schema-3i.sql', import.meta.url), 'utf8');
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const NEW = ['hosts', 'host_grants', 'events_grant_actor', 'sqlite_autoindex_hosts_1', 'sqlite_autoindex_host_grants_1'];
|
||||
// Row 236 (#1742): 3j→3k's objects, after this step. Their test is gitea-migration.test.mjs.
|
||||
const LATER = ['gitea_users', 'gitea_grants', 'sqlite_autoindex_gitea_users_1', 'sqlite_autoindex_gitea_grants_1'];
|
||||
function source3i(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'grant-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -35,7 +37,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')").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')").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(); }
|
||||
@@ -61,6 +63,7 @@ async function engine(root, name, { store, schema, step = (s) => s }) {
|
||||
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'])
|
||||
copyFileSync(here('../src/migrations/' + file), join(moduleRoot, 'src/migrations', file));
|
||||
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'));
|
||||
return (await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')))).Store;
|
||||
}
|
||||
function fresh(t) {
|
||||
@@ -84,9 +87,9 @@ 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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(rows(path), before);
|
||||
const now = master(path);
|
||||
const now = master(path).filter(r => !LATER.includes(r.name));
|
||||
assert.deepEqual(now.filter(r => !NEW.includes(r.name)), was);
|
||||
assert.deepEqual(now.filter(r => NEW.includes(r.name)).map(r => r.name).sort(), [...NEW].sort());
|
||||
const store = new Store(root);
|
||||
@@ -109,7 +112,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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
});
|
||||
|
||||
test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j', async t => {
|
||||
@@ -120,14 +123,14 @@ test('the 3i engine refuses a 3j store, migrating or not, and leaves it at 3j',
|
||||
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 = '3j';", "export const SCHEMA_VERSION = '3i';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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), '3j');
|
||||
assert.equal(version(path), '3k');
|
||||
assert.deepEqual(master(path), was);
|
||||
assert.deepEqual(rows(path), after);
|
||||
});
|
||||
|
||||
@@ -55,7 +55,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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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) });
|
||||
|
||||
@@ -520,7 +520,8 @@ test('v3b prototype refusals, views and append-only mutations', (t) => {
|
||||
// Row 141 (#1635) adds instance_names and its 4 triggers; names.test.mjs tests them.
|
||||
// Row 124 adds usage_records and its three append-only triggers.
|
||||
// Row 234 (#1740) adds hosts, host_grants and the events_grant_actor guard; grant-migration.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 18);
|
||||
// Row 236 (#1742) adds gitea_users and gitea_grants; gitea-migration.test.mjs tests them.
|
||||
assert.equal(count('table') - 1, 20);
|
||||
assert.equal(count('trigger'), 71);
|
||||
assert.equal(count('view'), 5);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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'), []);
|
||||
|
||||
@@ -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 = '3j';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3k';", "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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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 3j ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3k ready', ''].join('\n'));
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ test('REQ-LIMIT-4 3g copy migrates through 3h and 3i to 3j preserving every old
|
||||
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, '3j');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3k');
|
||||
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');
|
||||
|
||||
@@ -1061,6 +1061,46 @@ A status line shows `revoking:` with the tags of a revoke still in progress.
|
||||
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
|
||||
command. A usage error exits 4 and a bus refusal 2.
|
||||
|
||||
## grants (row 236)
|
||||
|
||||
`mosaic grants status gitea [--json] [--tester]` prints the Gitea grant path's state: the admin
|
||||
slot, each grant and open decision, settle-pending rotations, held agents, service left behind and
|
||||
revokes in progress. `run DECISION` starts the executor of a Gitea grant the human approved.
|
||||
`revoke AGENT gitea --reason R` takes the agent's Gitea token away and deletes it at Gitea. `sweep
|
||||
gitea` retries what a failure left. `admin set gitea --url URL --account NAME --rotate-by DATE`
|
||||
reads the admin token on stdin, never argv; `admin rotate gitea --rotate-by DATE`, `admin rotate
|
||||
gitea --finish` and `admin remove gitea` keep the slot. run and admin are the human's alone. Nothing
|
||||
prints a token. A usage error exits 4 and a bus refusal 2.
|
||||
|
||||
## Access (FED R5, #414)
|
||||
|
||||
```
|
||||
mosaic access list --business ID [--json]
|
||||
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
|
||||
mosaic access revoke --business ID --user ID [--grant GID] --revision N
|
||||
```
|
||||
|
||||
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
|
||||
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
|
||||
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
|
||||
write runs the file's checks on the new document first, bumps the revision by one, and renames
|
||||
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
|
||||
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
|
||||
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
|
||||
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
|
||||
`<dataRoot>/state/access-log.jsonl`.
|
||||
|
||||
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
|
||||
writable by group or other. If it is not, or a business file fails to load, the command refuses
|
||||
with exit 3, names the file and the key path, and writes nothing.
|
||||
|
||||
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
|
||||
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
|
||||
once. Removing a provider from `console.sso` takes effect only after a Console restart.
|
||||
|
||||
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
|
||||
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
|
||||
|
||||
## Token usage
|
||||
|
||||
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
|
||||
// The human edits <configDir>/access.json here. Each write is checked against the
|
||||
// instance's context before it lands, bumps the revision by one, and appends one
|
||||
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
|
||||
// through humanTransport: a bus that does not see a human shell refuses it, so
|
||||
// no agent and no tester can write. The context (providers, peers, businesses,
|
||||
// ssoSubjects) comes in as a function; the caller decides where it comes from.
|
||||
|
||||
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join, dirname } from "node:path";
|
||||
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
|
||||
import { rolesDir } from "./config.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { refuseInsideAgent } from "./transport.mjs";
|
||||
|
||||
export const ACCESS_USAGE = [
|
||||
"mosaic access list --business ID [--json]",
|
||||
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
|
||||
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
|
||||
].join("\n");
|
||||
|
||||
// The help text. A grant's `instances` is the real limit: the granted instance
|
||||
// runs with the owner's role, so a collaborator drives it with that authority.
|
||||
// Removing a user acts at once. Removing a provider from console.sso waits for a
|
||||
// Console restart, so take the users out of access.json first. Peer homes are
|
||||
// empty until the federation key lands (row 203), so a grant naming a peer
|
||||
// home refuses until then. A grant naming a subject needs console.sso.
|
||||
export const ACCESS_HELP = `${ACCESS_USAGE}
|
||||
|
||||
A grant gives a user exactly what the granted instances can do. The instances
|
||||
list is the real limit: a collaborator's message drives a granted instance with
|
||||
the owner's role.
|
||||
To cut a user off, revoke them here first. It acts at once. Removing a provider
|
||||
from console.sso takes effect only after a Console restart, so do that second.
|
||||
Peer homes are not read yet (row 203 adds the federation key), so a grant that
|
||||
names a peer home refuses. A grant that names a sign-in subject needs console.sso
|
||||
in the system config; without it every such grant refuses.
|
||||
The command runs as the instance's uid: access.json must belong to it and must
|
||||
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
|
||||
|
||||
// The instance's context for checkAccess, read from the system config and the business
|
||||
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
|
||||
// has an id and a subjects list). peers is empty until row 203. A business file that
|
||||
// fails to load fails the whole command.
|
||||
export function accessContextFor(system, env) {
|
||||
const providers = system.console?.sso?.providers ?? [];
|
||||
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
|
||||
const dir = join(configDir(env), "businesses");
|
||||
const businesses = {};
|
||||
if (existsSync(dir)) {
|
||||
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
|
||||
const id = file.slice(0, -".json".length);
|
||||
const doc = loadBusiness(id, { dir: configDir(env), rolesDir: rolesDir(env) });
|
||||
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
|
||||
}
|
||||
}
|
||||
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
|
||||
}
|
||||
|
||||
// Every verb needs --business: the human proof runs through that business's bus.
|
||||
// --tester is refused outright on every verb, before any bus call.
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--json" || a === "--tester") out.flags.add(a);
|
||||
else if (a.startsWith("--")) {
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
|
||||
out.values[a] = args[++i];
|
||||
} else out.positional.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const list = (value) => (value === "*" ? ["*"] : value.split(",").filter(Boolean));
|
||||
|
||||
function readCurrent(file, context) {
|
||||
if (!existsSync(file)) return { document: { accessVersion: 1, revision: null, users: [], grants: [] }, revision: 0 };
|
||||
const document = loadAccess(file, context);
|
||||
return { document, revision: document.revision };
|
||||
}
|
||||
|
||||
// The file calls the write path makes. A test passes a stand-in through deps.fs.
|
||||
const realFs = { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync };
|
||||
|
||||
// Atomic write: a temp file in the same directory, 0600, fsynced, then renamed over
|
||||
// the old file. A reader sees the old bytes or the new bytes, never a mix. A failure
|
||||
// before or at the rename removes the temp file and leaves the old bytes in place.
|
||||
function writeAtomic(file, text, fs = realFs) {
|
||||
const tmp = join(dirname(file), `.access-${randomBytes(8).toString("hex")}.tmp`);
|
||||
try {
|
||||
const fd = fs.openSync(tmp, "wx", 0o600);
|
||||
try {
|
||||
fs.writeSync(fd, text);
|
||||
fs.fsyncSync(fd);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
fs.renameSync(tmp, file);
|
||||
} catch (error) {
|
||||
try {
|
||||
fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
// Already gone: the temp file was never created.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function receipt(dataRoot, line) {
|
||||
const dir = join(dataRoot, "state");
|
||||
mkdirSync(dir, { recursive: true });
|
||||
appendFileSync(join(dir, "access-log.jsonl"), `${JSON.stringify(line)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
// Two writers never interleave: the read, the check and the rename happen under an
|
||||
// exclusive lock file, `<access.json>.lock`, mode 0600. The file holds the holder's
|
||||
// pid and a newline. A lock whose holder is alive refuses. A lock that is empty,
|
||||
// unreadable, or whose pid is gone also refuses, and nothing reclaims it: a human
|
||||
// removes it once no write is running. Fail closed, with no reclaim race.
|
||||
const BUSY = "refused: the access file is being written by another command; try again";
|
||||
|
||||
function lockPid(lock) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(lock, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const pid = Number(text.trim());
|
||||
return Number.isSafeInteger(pid) && pid >= 1 ? pid : null;
|
||||
}
|
||||
|
||||
function pidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return error.code !== "ESRCH";
|
||||
}
|
||||
}
|
||||
|
||||
function lockRefusal(lock) {
|
||||
const pid = lockPid(lock);
|
||||
if (pid !== null && pidAlive(pid)) return BUSY;
|
||||
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
||||
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
||||
}
|
||||
|
||||
function takeLock(lock, fs) {
|
||||
try {
|
||||
return fs.openSync(lock, "wx", 0o600);
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") throw error;
|
||||
}
|
||||
throw new CliError(lockRefusal(lock), 3);
|
||||
}
|
||||
|
||||
export function withLock(file, fn, fs = realFs) {
|
||||
const lock = `${file}.lock`;
|
||||
const fd = takeLock(lock, fs);
|
||||
try {
|
||||
fs.writeSync(fd, `${process.pid}\n`);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.unlinkSync(lock);
|
||||
}
|
||||
}
|
||||
|
||||
// The one human-proof call: project.list, which the bus gates with #human
|
||||
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
|
||||
// project names. Row R4 swaps this call for access.reload.
|
||||
async function proveHuman(transport, business) {
|
||||
return transport(business)("project.list", {});
|
||||
}
|
||||
|
||||
// Resolve a --projects item: "*" and UUIDs pass through, anything else is a project
|
||||
// name looked up in project.list at grant time (the id is what gets written).
|
||||
function resolveProjects(items, rows) {
|
||||
return items.map((item) => {
|
||||
if (item === "*" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item)) return item;
|
||||
const hits = rows.filter((r) => r.name === item);
|
||||
if (hits.length !== 1) throw new CliError(`refused: project-not-found: no project named ${item} in this business (or the name is ambiguous)`, 2);
|
||||
return hits[0].id;
|
||||
});
|
||||
}
|
||||
|
||||
// deps: { system: { dataRoot }, transport(business) -> call(verb, args), contextFor() -> context, configDir?, now?, fs? }
|
||||
export async function accessCommand(args, io, deps) {
|
||||
refuseInsideAgent(io.env);
|
||||
try {
|
||||
return await accessVerb(args, io, deps);
|
||||
} catch (error) {
|
||||
// A file or business doc the checks refuse to load (group-writable, a wrong uid, a
|
||||
// hand edit) is a refusal with exit 3 and the file and key path, not a crash.
|
||||
if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 3);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function accessVerb(args, io, deps) {
|
||||
const parsed = parse(args);
|
||||
const sub = parsed.positional[0];
|
||||
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
|
||||
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
|
||||
const business = parsed.values["--business"];
|
||||
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
const rows = await proveHuman(deps.transport, business);
|
||||
const file = deps.file ?? accessFilePath(deps.configDir ?? configDir(io.env));
|
||||
// Built after the human proof, so a refused caller never reads the business files.
|
||||
const context = deps.contextFor();
|
||||
|
||||
if (sub === "list") {
|
||||
const { document } = readCurrent(file, context);
|
||||
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(document, null, 2)}\n` : formatList(document));
|
||||
return;
|
||||
}
|
||||
|
||||
const expected = parsed.values["--revision"];
|
||||
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
|
||||
const user = parsed.values["--user"];
|
||||
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
|
||||
|
||||
const result = withLock(file, () => {
|
||||
const { document, revision } = readCurrent(file, context);
|
||||
if (Number(expected) !== revision) {
|
||||
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
|
||||
}
|
||||
let next;
|
||||
let touched;
|
||||
if (sub === "grant") {
|
||||
const level = parsed.values["--level"];
|
||||
if (!parsed.values["--projects"] || !parsed.values["--instances"] || level === undefined) {
|
||||
throw new CliError(`grant needs --projects, --instances and --level\n${ACCESS_USAGE}`, 4);
|
||||
}
|
||||
if (!LEVELS.includes(level)) throw new CliError(`--level takes ${LEVELS.join(", ")}`, 2);
|
||||
const id = `g-${randomBytes(4).toString("hex")}`;
|
||||
const grant = {
|
||||
id,
|
||||
user,
|
||||
business,
|
||||
projects: resolveProjects(list(parsed.values["--projects"]), rows),
|
||||
instances: list(parsed.values["--instances"]),
|
||||
level,
|
||||
};
|
||||
if (parsed.values["--expires"] !== undefined) grant.expires = parsed.values["--expires"];
|
||||
touched = [id];
|
||||
next = { ...document, revision: revision + 1, grants: [...document.grants, grant] };
|
||||
} else {
|
||||
const only = parsed.values["--grant"];
|
||||
const drop = (g) => g.user === user && g.business === business && (only === undefined || g.id === only);
|
||||
touched = document.grants.filter(drop).map((g) => g.id);
|
||||
if (touched.length === 0) throw new CliError(`refused: no grant for ${user} at ${business} to revoke`, 2);
|
||||
next = { ...document, revision: revision + 1, grants: document.grants.filter((g) => !drop(g)) };
|
||||
}
|
||||
// Every check runs on the new document before a byte is written.
|
||||
const checked = checkAccess(next, context);
|
||||
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
|
||||
return { checked, revision, touched };
|
||||
}, deps.fs);
|
||||
|
||||
receipt(deps.system.dataRoot, {
|
||||
at: (deps.now ?? (() => new Date()))().toISOString(),
|
||||
verb: sub,
|
||||
business,
|
||||
user,
|
||||
actor: "human",
|
||||
revisionBefore: result.revision,
|
||||
revisionAfter: result.checked.revision,
|
||||
grants: result.touched,
|
||||
});
|
||||
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
|
||||
}
|
||||
|
||||
function formatList(doc) {
|
||||
const lines = [`access file revision ${doc.revision ?? "none"}`];
|
||||
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
|
||||
for (const g of doc.grants) {
|
||||
lines.push(`grant ${g.id} user ${g.user} business ${g.business} projects ${g.projects.join(",")} instances ${g.instances.join(",")} level ${g.level}${g.expires ? ` expires ${g.expires}` : ""}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
@@ -19,7 +19,9 @@ import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
|
||||
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
|
||||
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
|
||||
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
|
||||
import { grantsCommand, GRANTS_USAGE } from "./grants.mjs";
|
||||
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
|
||||
import { accessCommand, accessContextFor } from "./access.mjs";
|
||||
import { createChannelCaller } from "../../plugins/src/channel.mjs";
|
||||
import { PluginError } from "../../plugins/src/kinds.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
@@ -42,6 +44,7 @@ export const USAGE = `usage:
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
${HOSTS_USAGE}
|
||||
${GRANTS_USAGE}
|
||||
${INSTANCES_USAGE}
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
@@ -577,6 +580,15 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
|
||||
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
|
||||
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
|
||||
if (verb === "access") {
|
||||
const system = deps.system ?? loadSystem({ env: io.env });
|
||||
return accessCommand(rest, io, {
|
||||
system,
|
||||
transport: (b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }),
|
||||
contextFor: () => deps.accessContext ?? accessContextFor(system, io.env),
|
||||
now: deps.now,
|
||||
});
|
||||
}
|
||||
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
|
||||
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
@@ -584,6 +596,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 234 (#1740, r6 E4): the host records.
|
||||
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 236 (#1742, #436b r5): the Gitea grant path; admin set reads the admin token on stdin.
|
||||
if (verb === "grants") return grantsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
|
||||
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (verb === "talk") return talk(rest, io, deps);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// `mosaic grants` (row 236, #1742, #436b r5): the broker's grants verbs for the Gitea grant path. run
|
||||
// starts the executor of a decision the human said yes to, and admin set, rotate and remove keep the
|
||||
// admin slot; those are the human's alone. status is the human's, the tester's or the chief's; revoke
|
||||
// and sweep the human's or, with --tester, the tester's. admin set reads the admin token on stdin, never
|
||||
// argv, and nothing here prints a token: the answers carry ids and names only.
|
||||
import { CliError } from "./errors.mjs";
|
||||
|
||||
export const GRANTS_USAGE =
|
||||
"mosaic grants status gitea [--json] [--tester] [--business ID] | run DECISION [--business ID] | " +
|
||||
"revoke AGENT gitea --reason R [--tester] [--business ID] | sweep gitea [--tester] [--business ID] | " +
|
||||
"admin set gitea --url URL --account NAME --rotate-by DATE [--business ID] | " +
|
||||
"admin rotate gitea --rotate-by DATE | admin rotate gitea --finish | admin remove gitea";
|
||||
const MAX_TOKEN = 16384;
|
||||
const VALUES = ["--business", "--reason", "--url", "--account", "--rotate-by"];
|
||||
|
||||
function parse(args) {
|
||||
const out = { positional: [], flags: new Set(), values: {} };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--json" || arg === "--tester" || arg === "--finish") {
|
||||
if (out.flags.has(arg)) throw new CliError(GRANTS_USAGE, 4);
|
||||
out.flags.add(arg);
|
||||
} else if (VALUES.includes(arg)) {
|
||||
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(GRANTS_USAGE, 4);
|
||||
out.values[arg] = args[++i];
|
||||
} else if (arg.startsWith("--")) throw new CliError(GRANTS_USAGE, 4);
|
||||
else out.positional.push(arg);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The whole of stdin, one token, as `mosaic credentials issue` reads it.
|
||||
async function readToken(stdin) {
|
||||
let text = "";
|
||||
for await (const chunk of stdin) {
|
||||
text += chunk;
|
||||
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
|
||||
}
|
||||
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
|
||||
return text;
|
||||
}
|
||||
|
||||
// Each shape: the positional count after the verb, the flags and values it takes and needs.
|
||||
const SHAPES = {
|
||||
status: { n: 1, flags: ["--json", "--tester"], need: [], may: ["--business"] },
|
||||
run: { n: 1, flags: [], need: [], may: ["--business"] },
|
||||
revoke: { n: 2, flags: ["--tester"], need: ["--reason"], may: ["--business"] },
|
||||
sweep: { n: 1, flags: ["--tester"], need: [], may: ["--business"] },
|
||||
"admin set": { n: 1, flags: [], need: ["--url", "--account", "--rotate-by"], may: ["--business"] },
|
||||
"admin rotate": { n: 1, flags: ["--finish"], need: [], may: ["--rotate-by", "--business"] },
|
||||
"admin remove": { n: 1, flags: [], need: [], may: ["--business"] },
|
||||
};
|
||||
|
||||
const lines = (s) => {
|
||||
const out = [];
|
||||
out.push(s.admin?.error ? `admin: ${s.admin.error}` : s.admin ? `admin: ${s.admin.account} on ${s.admin.url}, rotate by ${s.admin.rotateBy}${s.admin.rotationDue ? " (due)" : ""}` : "admin: none");
|
||||
if (s.adminTokenLeft) out.push(`admin token left: ${s.adminTokenLeft}`);
|
||||
for (const g of s.grants) out.push(["grant", g.agent, g.user, `token ${g.tokenId}`, g.projects.join(","), `rotate by ${g.rotateBy}`, g.decision].join("\t"));
|
||||
for (const o of s.open) out.push(["open", o.decision, o.agent].join("\t"));
|
||||
for (const o of s.running) out.push(["running", o.decision, o.agent, o.step ?? "spent"].join("\t"));
|
||||
for (const o of s.settlePending) out.push(["settle-pending", o.decision, o.agent].join("\t"));
|
||||
for (const o of s.tokenUnknown) out.push(["token-unknown", o.agent, o.decision].join("\t"));
|
||||
for (const e of s.serviceLeft) out.push(["left", e.agent, e.user, String(e.id)].join("\t"));
|
||||
for (const r of s.revoking) out.push(["revoking", r.agent, r.revoke].join("\t"));
|
||||
return out.join("\n") + "\n";
|
||||
};
|
||||
|
||||
export async function grantsCommand(args, io, callFor) {
|
||||
const parsed = parse(args);
|
||||
let [verb, ...rest] = parsed.positional;
|
||||
if (verb === "admin") {
|
||||
verb = `admin ${rest[0] ?? ""}`;
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
// Own keys only, so `constructor` is a usage error, not a verb.
|
||||
const shape = Object.hasOwn(SHAPES, verb ?? "") ? SHAPES[verb] : null;
|
||||
if (
|
||||
!shape || rest.length !== shape.n ||
|
||||
[...parsed.flags].some((f) => !shape.flags.includes(f)) ||
|
||||
Object.keys(parsed.values).some((v) => !shape.need.includes(v) && !shape.may.includes(v)) ||
|
||||
shape.need.some((v) => !Object.hasOwn(parsed.values, v)) ||
|
||||
(verb !== "run" && rest.at(-1) !== "gitea") ||
|
||||
(verb === "admin rotate" && parsed.flags.has("--finish") === Object.hasOwn(parsed.values, "--rotate-by"))
|
||||
)
|
||||
throw new CliError(GRANTS_USAGE, 4);
|
||||
try {
|
||||
const call = callFor(parsed);
|
||||
const v = parsed.values;
|
||||
let request;
|
||||
if (verb === "status") request = ["grants.status", { service: "gitea" }];
|
||||
else if (verb === "run") request = ["grants.run", { decision: rest[0] }];
|
||||
else if (verb === "revoke") request = ["grants.revoke", { service: "gitea", agent: rest[0], reason: v["--reason"] }];
|
||||
else if (verb === "sweep") request = ["grants.sweep", { service: "gitea" }];
|
||||
else if (verb === "admin set")
|
||||
request = ["grants.admin.set", { service: "gitea", url: v["--url"], account: v["--account"], rotateBy: v["--rotate-by"], token: await readToken(io.stdin) }];
|
||||
else if (verb === "admin rotate")
|
||||
request = ["grants.admin.rotate", parsed.flags.has("--finish") ? { service: "gitea", finish: true } : { service: "gitea", rotateBy: v["--rotate-by"] }];
|
||||
else request = ["grants.admin.remove", { service: "gitea" }];
|
||||
const result = await call(...request);
|
||||
io.stdout.write(verb === "status" && !parsed.flags.has("--json") ? lines(result) : JSON.stringify(result, null, 2) + "\n");
|
||||
} catch (error) {
|
||||
if (error instanceof CliError) throw error;
|
||||
throw new CliError(error.message, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
|
||||
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
|
||||
// The one test that runs the real human-cli.mjs does it on a scratch bus from
|
||||
// agent ancestry, so the bus must refuse, and nothing is written.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as nodeFs from "node:fs";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
|
||||
import { CliError } from "../src/errors.mjs";
|
||||
import { socketPath } from "../src/config.mjs";
|
||||
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
|
||||
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
|
||||
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
|
||||
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
|
||||
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
|
||||
const CONTEXT = {
|
||||
providers: ["authentik"],
|
||||
peers: [],
|
||||
businesses: { acme: { human: "jason", instances: ["coder"] } },
|
||||
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
|
||||
};
|
||||
const DOC = {
|
||||
accessVersion: 1,
|
||||
revision: 1,
|
||||
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
|
||||
grants: [],
|
||||
};
|
||||
|
||||
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
|
||||
// that sees no human shell when FAKE_MODE is "refuse".
|
||||
const BODY = `
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
const input = JSON.parse(readFileSync(0, "utf8"));
|
||||
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
|
||||
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
|
||||
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
|
||||
`;
|
||||
|
||||
function setup(t, { mode = "ok", doc = DOC } = {}) {
|
||||
const root = tmp(t, "mosaic-access-");
|
||||
const config = join(root, "config");
|
||||
mkdirSync(config, { recursive: true });
|
||||
const dataRoot = join(root, "data");
|
||||
const log = join(root, "fake.log");
|
||||
const cli = fakeCli(root, BODY);
|
||||
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
|
||||
const file = join(config, "access.json");
|
||||
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
|
||||
const deps = {
|
||||
system: { dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
now: () => new Date("2026-10-20T20:00:00Z"),
|
||||
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
|
||||
};
|
||||
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
|
||||
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
|
||||
}
|
||||
|
||||
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
|
||||
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
|
||||
|
||||
async function run(args, deps, env = humanEnv()) {
|
||||
const i = io(env);
|
||||
await accessCommand(args, i, deps);
|
||||
return i.out.text;
|
||||
}
|
||||
|
||||
async function refusal(promise, pattern) {
|
||||
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
|
||||
return promise.catch((e) => e);
|
||||
}
|
||||
|
||||
test("list prints the revision and the users", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run(["list", "--business", "acme"], s.deps);
|
||||
assert.match(out, /access file revision 1/);
|
||||
assert.match(out, /user alice home local/);
|
||||
});
|
||||
|
||||
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
|
||||
const s = setup(t);
|
||||
const out = await run([...GRANT, "--revision", "1"], s.deps);
|
||||
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.equal(doc.grants.length, 1);
|
||||
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
|
||||
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.equal(line.verb, "grant");
|
||||
assert.equal(line.revisionBefore, 1);
|
||||
assert.equal(line.revisionAfter, 2);
|
||||
assert.equal(line.actor, "human");
|
||||
});
|
||||
|
||||
test("a viewer grant writes the viewer level, one revision up", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "viewer", "--revision", "1"], s.deps);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 2);
|
||||
assert.equal(doc.grants[0].level, "viewer");
|
||||
});
|
||||
|
||||
test("a second write appends a second receipt and leaves the first intact", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
|
||||
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
|
||||
assert.equal(lines[0].revisionAfter, 2);
|
||||
});
|
||||
|
||||
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts().trim().split("\n").length, 1);
|
||||
});
|
||||
|
||||
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
|
||||
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
|
||||
});
|
||||
|
||||
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
|
||||
const s = setup(t);
|
||||
writeFileSync(`${s.file}.lock`, "");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), true);
|
||||
});
|
||||
|
||||
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
|
||||
const s = setup(t);
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
|
||||
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
|
||||
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
|
||||
});
|
||||
|
||||
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
|
||||
const s = setup(t);
|
||||
let mode;
|
||||
withLock(s.file, () => {
|
||||
mode = statSync(`${s.file}.lock`).mode & 0o777;
|
||||
});
|
||||
assert.equal(mode, 0o600);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
});
|
||||
|
||||
test("a grant naming an unknown instance is refused before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(bad, s.deps), /refused: /);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a project name resolves to its id at grant time", async (t) => {
|
||||
const s = setup(t);
|
||||
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await run(args, s.deps);
|
||||
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
|
||||
});
|
||||
|
||||
test("an unknown project name refuses before any write", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
|
||||
await refusal(run(args, s.deps), /project-not-found/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
|
||||
const s = setup(t);
|
||||
await run([...GRANT, "--revision", "1"], s.deps);
|
||||
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
|
||||
const doc = s.read();
|
||||
assert.equal(doc.revision, 3);
|
||||
assert.deepEqual(doc.grants, []);
|
||||
});
|
||||
|
||||
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("an agent caller is refused before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(existsSync(s.log), false);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
});
|
||||
|
||||
test("--tester is refused on every verb before the bus is asked", async (t) => {
|
||||
const s = setup(t);
|
||||
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
|
||||
const args = [...verb, "--business", "acme", "--tester"];
|
||||
const err = await refusal(run(args, s.deps), /not --tester/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
}
|
||||
assert.equal(existsSync(s.log), false);
|
||||
});
|
||||
|
||||
test("a bus that refuses the human proof blocks the write", async (t) => {
|
||||
const s = setup(t, { mode: "refuse" });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-ctx-");
|
||||
const f = fixture(root, "acme");
|
||||
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
|
||||
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(ctx.providers, ["authentik"]);
|
||||
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
|
||||
assert.deepEqual(ctx.peers, []);
|
||||
assert.equal(ctx.businesses.acme.human, "jason");
|
||||
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
|
||||
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
|
||||
assert.deepEqual(bare.ssoSubjects, []);
|
||||
assert.deepEqual(bare.providers, []);
|
||||
});
|
||||
|
||||
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
|
||||
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
|
||||
const root = tmp(t, "mosaic-access-real-");
|
||||
const f = fixture(root, "acme");
|
||||
const config = join(root, "config");
|
||||
const bus = await startBroker({
|
||||
dataRoot: f.dataRoot,
|
||||
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
||||
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
|
||||
});
|
||||
t.after(() => bus.close());
|
||||
writeJson(join(config, "access.json"), DOC);
|
||||
const file = join(config, "access.json");
|
||||
const before = readFileSync(file, "utf8");
|
||||
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
|
||||
const deps = {
|
||||
system: { dataRoot: f.dataRoot },
|
||||
configDir: config,
|
||||
contextFor: () => CONTEXT,
|
||||
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
|
||||
};
|
||||
// The bus closes the socket on a failed human proof, so the client reports
|
||||
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
|
||||
// outside R5 (BUILD.md). The test pins it, and nothing is written.
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
|
||||
assert.equal(err.code, "outcome-unknown");
|
||||
assert.equal(err.exitCode, 1);
|
||||
assert.equal(readFileSync(file, "utf8"), before);
|
||||
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Row 236 (#1742, #436b r5): `mosaic grants status|run|revoke|sweep|admin`. A fake transport records the
|
||||
// calls; no bus runs here. The bus side of each verb is in packages/bus/tests/gitea-grant.test.mjs. The
|
||||
// admin token is a fake made here, read once from stdin and never printed.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { main } from "../src/cli.mjs";
|
||||
import { io, tmp } from "./helpers.mjs";
|
||||
|
||||
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
|
||||
const STATUS = {
|
||||
admin: { url: "https://git.example.test", account: "mosaic-grant", rotateBy: "2026-12-20", setAt: "2026-10-21T00:00:00.000Z", rotationDue: false },
|
||||
adminTokenLeft: "mosaic-grant-bootstrap",
|
||||
users: [{ agent: "coder", user: "demo-coder", id: 7 }],
|
||||
grants: [{ agent: "coder", user: "demo-coder", tokenId: 9, projects: ["app", "lib"], rotateBy: "2026-11-20", decision: "d1" }],
|
||||
open: [{ decision: "d2", agent: "coder", spentAtNextStart: true }],
|
||||
running: [{ decision: "d3", agent: null, step: null }],
|
||||
settlePending: [{ decision: "d4", agent: "coder" }],
|
||||
tokenUnknown: [{ agent: "ops", decision: "d5" }],
|
||||
serviceLeft: [{ agent: "coder", user: "demo-coder", id: 11 }],
|
||||
revoking: [{ agent: "coder", revoke: "r1" }],
|
||||
};
|
||||
|
||||
function deps(calls) {
|
||||
const answer = (who) => (business) => async (verb, args) => {
|
||||
calls.push({ who, business, verb, args });
|
||||
if (verb === "grants.status") return STATUS;
|
||||
if (verb === "grants.run") return { decision: args.decision, agent: "coder", started: true };
|
||||
return { ok: true };
|
||||
};
|
||||
return { system: { dataRoot: "/nonexistent" }, transport: answer("human"), testerTransport: answer("tester") };
|
||||
}
|
||||
const withStdin = (x, text) => (x.stdin.end(text), x);
|
||||
|
||||
test("row 236: grants status prints one line per entry, and --json the answer", async () => {
|
||||
const calls = [];
|
||||
const x = io();
|
||||
await main(["grants", "status", "gitea", "--business", "acme"], x, deps(calls));
|
||||
assert.deepEqual(calls, [{ who: "human", business: "acme", verb: "grants.status", args: { service: "gitea" } }]);
|
||||
assert.equal(x.out.text, [
|
||||
"admin: mosaic-grant on https://git.example.test, rotate by 2026-12-20",
|
||||
"admin token left: mosaic-grant-bootstrap",
|
||||
"grant\tcoder\tdemo-coder\ttoken 9\tapp,lib\trotate by 2026-11-20\td1",
|
||||
"open\td2\tcoder",
|
||||
"running\td3\t\tspent",
|
||||
"settle-pending\td4\tcoder",
|
||||
"token-unknown\tops\td5",
|
||||
"left\tcoder\tdemo-coder\t11",
|
||||
"revoking\tcoder\tr1",
|
||||
].join("\n") + "\n");
|
||||
const y = io();
|
||||
await main(["grants", "status", "gitea", "--json", "--tester", "--business", "acme"], y, deps(calls));
|
||||
assert.deepEqual(JSON.parse(y.out.text), STATUS);
|
||||
assert.equal(calls[1].who, "tester");
|
||||
const z = io();
|
||||
const due = { ...deps([]), transport: () => async () => ({ ...STATUS, admin: { ...STATUS.admin, rotationDue: true }, adminTokenLeft: null }) };
|
||||
await main(["grants", "status", "gitea", "--business", "acme"], z, due);
|
||||
assert.match(z.out.text, /^admin: mosaic-grant on https:\/\/git\.example\.test, rotate by 2026-12-20 \(due\)\ngrant\t/);
|
||||
const none = { ...deps([]), transport: () => async () => ({ ...STATUS, admin: null, adminTokenLeft: null }) };
|
||||
const w = io();
|
||||
await main(["grants", "status", "gitea", "--business", "acme"], w, none);
|
||||
assert.match(w.out.text, /^admin: none\n/);
|
||||
});
|
||||
|
||||
test("row 236: run, revoke, sweep and admin rotate and remove send their args", async () => {
|
||||
const calls = [];
|
||||
const x = io();
|
||||
await main(["grants", "run", "d1", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "revoke", "coder", "gitea", "--reason", "left the team", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "revoke", "coder", "gitea", "--reason", "r", "--tester", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "sweep", "gitea", "--tester", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "admin", "rotate", "gitea", "--rotate-by", "2027-01-20", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "admin", "rotate", "gitea", "--finish", "--business", "acme"], x, deps(calls));
|
||||
await main(["grants", "admin", "remove", "gitea", "--business", "acme"], x, deps(calls));
|
||||
assert.deepEqual(calls.map(({ who, verb, args }) => ({ who, verb, args })), [
|
||||
{ who: "human", verb: "grants.run", args: { decision: "d1" } },
|
||||
{ who: "human", verb: "grants.revoke", args: { service: "gitea", agent: "coder", reason: "left the team" } },
|
||||
{ who: "tester", verb: "grants.revoke", args: { service: "gitea", agent: "coder", reason: "r" } },
|
||||
{ who: "tester", verb: "grants.sweep", args: { service: "gitea" } },
|
||||
{ who: "human", verb: "grants.admin.rotate", args: { service: "gitea", rotateBy: "2027-01-20" } },
|
||||
{ who: "human", verb: "grants.admin.rotate", args: { service: "gitea", finish: true } },
|
||||
{ who: "human", verb: "grants.admin.remove", args: { service: "gitea" } },
|
||||
]);
|
||||
assert.match(x.out.text, /^\{\n {2}"decision": "d1",\n {2}"agent": "coder",\n {2}"started": true\n\}\n/);
|
||||
});
|
||||
|
||||
test("row 236: admin set sends the stdin token once to grants.admin.set and prints no value", async () => {
|
||||
const calls = [];
|
||||
const token = randomBytes(20).toString("hex");
|
||||
const x = withStdin(io(), token + "\n");
|
||||
await main(["grants", "admin", "set", "gitea", "--url", "https://git.example.test", "--account", "mosaic-grant", "--rotate-by", "2026-12-20", "--business", "acme"], x, deps(calls));
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].verb, "grants.admin.set");
|
||||
const { token: sent, ...rest } = calls[0].args;
|
||||
assert.ok(sent === token + "\n", "the stdin token went to the bus as read");
|
||||
assert.deepEqual(rest, { service: "gitea", url: "https://git.example.test", account: "mosaic-grant", rotateBy: "2026-12-20" });
|
||||
assert.ok(!x.out.text.includes(token) && !x.err.text.includes(token), "no token value printed");
|
||||
// An empty stdin, and one too long, refuse before any call.
|
||||
await assert.rejects(main(["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "2026-12-20", "--business", "acme"], withStdin(io(), "\n"), deps(calls)),
|
||||
(e) => (assert.equal(e.exitCode, 4), assert.match(e.message, /no token on stdin/), true));
|
||||
await assert.rejects(main(["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "2026-12-20", "--business", "acme"], withStdin(io(), "x".repeat(20000)), deps(calls)),
|
||||
(e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /too long/), true));
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test("row 236: a malformed grants command is a usage error and calls nothing", async () => {
|
||||
const calls = [];
|
||||
for (const argv of [
|
||||
["grants"], ["grants", "status"], ["grants", "status", "host"], ["grants", "status", "gitea", "x"], ["grants", "run"], ["grants", "run", "d1", "d2"],
|
||||
["grants", "run", "d1", "--tester"], ["grants", "revoke", "coder", "gitea"], ["grants", "revoke", "coder", "host", "--reason", "r"],
|
||||
["grants", "revoke", "gitea", "--reason", "r"], ["grants", "sweep"], ["grants", "sweep", "host"], ["grants", "sweep", "gitea", "--reason", "r"],
|
||||
["grants", "admin"], ["grants", "admin", "set", "gitea"], ["grants", "admin", "set", "gitea", "--url", "u", "--account", "a"],
|
||||
["grants", "admin", "set", "gitea", "--url", "u", "--account", "a", "--rotate-by", "d", "--tester"],
|
||||
["grants", "admin", "rotate", "gitea"], ["grants", "admin", "rotate", "gitea", "--finish", "--rotate-by", "2027-01-01"],
|
||||
["grants", "admin", "rotate", "gitea", "--finish", "--finish"], ["grants", "admin", "remove", "gitea", "--tester"], ["grants", "admin", "purge", "gitea"],
|
||||
["grants", "status", "gitea", "--business"], ["grants", "status", "gitea", "--force"], ["grants", "constructor", "gitea"],
|
||||
["grants", "__proto__", "gitea"], ["grants", "admin", "constructor", "gitea"], ["grants", "status", "gitea", "--json", "--json"],
|
||||
])
|
||||
await assert.rejects(main([...argv], io(), deps(calls)), (e) => (assert.equal(e.exitCode, 4, argv.join(" ")), assert.match(e.message, /mosaic grants/), true));
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("row 236: a bus refusal exits 2 with its code", async () => {
|
||||
const refusing = { system: { dataRoot: "/nonexistent" }, transport: () => async () => { throw new Error("grant-admin-missing"); } };
|
||||
await assert.rejects(main(["grants", "run", "d1", "--business", "acme"], io(), refusing), (e) => (assert.equal(e.exitCode, 2), assert.match(e.message, /grant-admin-missing/), true));
|
||||
});
|
||||
|
||||
test("row 236: scripts/mosaic sends grants to packages/cli, not the seat CLI", (t) => {
|
||||
const root = tmp(t, "mosaic-cli-grants-route-");
|
||||
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
|
||||
const r = spawnSync(MOSAIC, ["grants", "purge", "gitea"], { env, encoding: "utf8" });
|
||||
assert.equal(r.status, 4);
|
||||
assert.match(r.stderr, /^mosaic: mosaic grants status gitea/);
|
||||
assert.doesNotMatch(r.stderr, /mosaic launch </);
|
||||
});
|
||||
@@ -918,6 +918,8 @@ test('T46 row 181 item 4: every table in the bus schema is in exactly one of fou
|
||||
const hasAt=(n)=>db.prepare(`SELECT count(*) AS n FROM pragma_table_info(?) WHERE name='at'`).get(n).n===1;
|
||||
const always={decisions:'decision.raise writes action.allowed in the same transaction'};
|
||||
const untimed={
|
||||
gitea_grants:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
gitea_users:'no time column; row 236: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
host_grants:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
hosts:'no time column; row 234: each write goes in one transaction with a grant.* events row, which is counted',
|
||||
meta:'key and value only; its one UPDATE is the migration step',
|
||||
|
||||
+1
-1
@@ -41,6 +41,6 @@ if [ "${1:-}" = library ] && [ "${2:-}" = survey ]; then
|
||||
exec node "$REPO/packages/library/src/survey.mjs" "$@"
|
||||
fi
|
||||
case "${1:-}" in
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|grants|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
|
||||
esac
|
||||
exec node "$REPO/packages/seat/src/cli.mjs" "$@"
|
||||
|
||||
Reference in New Issue
Block a user