Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c0048911e | ||
|
|
7965efd0fb | ||
|
|
77dd43c9e3 | ||
|
|
8bf442be90 | ||
|
|
d73ea24ead | ||
|
|
69bf7f5ca7 |
@@ -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`.
|
||||
+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,17 @@ 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)
|
||||
|
||||
```
|
||||
|
||||
@@ -19,6 +19,7 @@ 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";
|
||||
@@ -43,6 +44,7 @@ export const USAGE = `usage:
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
${HOSTS_USAGE}
|
||||
${GRANTS_USAGE}
|
||||
${INSTANCES_USAGE}
|
||||
${PROJECT_USAGE}
|
||||
${PROJECT_BUS_USAGE}
|
||||
@@ -594,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,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|access) 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