diff --git a/agents/megavolt/work/queue-236/BUILD.md b/agents/megavolt/work/queue-236/BUILD.md new file mode 100644 index 000000000..58bdb0498 --- /dev/null +++ b/agents/megavolt/work/queue-236/BUILD.md @@ -0,0 +1,81 @@ +# 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. + +## 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) diff --git a/packages/bus/README.md b/packages/bus/README.md index 5c553eca2..a6a3e0f7a 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -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 ` 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 ` 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. Writes `grant.sweep` and `grant.swept {left,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 `/secrets/grants//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. | 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, diff --git a/packages/bus/schema.sql b/packages/bus/schema.sql index fe664c8e1..d6c5bcda3 100644 --- a/packages/bus/schema.sql +++ b/packages/bus/schema.sql @@ -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; diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index cb31394f9..0ef97f98a 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs'; import { loadRequirements } from '../../tasks/src/requirements.mjs'; import { MERGE, LandingRefusal, readLanding } from './landing.mjs'; -import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction } from '../../business/src/vocabulary.mjs'; +import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl } from '../../business/src/vocabulary.mjs'; // Kept closed while S1 lands; its vocabulary is the integration authority. export const ACTIONS = Object.freeze([ @@ -235,7 +235,7 @@ const GRANT_RUN = 'grant'; const grantName = (body) => [body?.action, body?.operation].some(n => typeof n === 'string' && n.startsWith('grant.')); // Every grant-evidence read filters on this (duckworth-0067 N1): a look-alike under another kind or // actor is not evidence. -const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'"; +export const GRANT_EVIDENCE = "kind='action.allowed' AND actor_role IS NULL AND actor_run='grant'"; // r6 R4: the host record's identity, hashed at raise and at each later check. export const registrySha256 = ({ address, port, user, sudo, hostKeys }) => createHash('sha256').update(JSON.stringify({ address, port, user, sudo: !!sudo, hostKeys })).digest('hex'); @@ -268,6 +268,14 @@ export function writeGrantEvent(store, at, business, body, subject = null) { return eid; } export const HOSTS_VERBS = Object.freeze(['hosts.status', 'hosts.check', 'hosts.remove', 'hosts.revoke', 'hosts.sweep']); +// Row 236 (#1742, #436b r5): the Gitea grant path's verbs. run and admin.* are the human's alone. +export const GRANTS_VERBS = Object.freeze([ + 'grants.status', 'grants.run', 'grants.revoke', 'grants.sweep', 'grants.admin.set', 'grants.admin.rotate', 'grants.admin.remove', +]); +// A Gitea id: a positive safe integer. +const giteaId = (x) => Number.isSafeInteger(x) && x > 0; +// A YYYY-MM-DD that is a real calendar day. +const calendarDay = (x) => typeof x === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(x) && new Date(x + 'T00:00:00Z').toISOString().slice(0, 10) === x; // The OpenSSH fingerprint of a " " host key line. host-grant.mjs has the same; the broker // doesn't import it, so the sandbox's RUNNER_FILES stay as they are. const fingerprintOf = (line) => @@ -369,12 +377,19 @@ export class Broker { #revokeUndo = []; #testerOn; #roleFile; + // Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of + // section 5's restart rows and of revokes a restart cut short, for reconcileGitea. + #giteaGrant; + #giteaKeys; + #giteaUndo = []; + #giteaRuns = new Set(); + #giteaRevokeUndo = []; // Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state. #stops = new Map(); #clock = 0; #processing; // credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use(). - constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null }) { + constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, roleFile = null, giteaGrant = null, giteaKeys = null }) { if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business'); // Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store. if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function'); @@ -396,6 +411,8 @@ export class Broker { this.#credentialVerbs = credentialVerbs; this.#hostGrant = hostGrant; this.#hostKeys = hostKeys; + this.#giteaGrant = giteaGrant; + this.#giteaKeys = giteaKeys; this.#project = project; this.#library = library; this.#usage = usage; @@ -1385,6 +1402,8 @@ export class Broker { // r6 section 6 (N4): a grant.revoked still pending has done step 1 and maybe step 2. The record is // gone, so the start sweep took any key file left. reconcileHosts runs step 3. for (const m of this.#openRevokes(business)) this.#revokeUndo.push({ business, ...m }); + // Row 236 (#436b r5 section 5): the Gitea grants' local part. + if (this.#giteaGrant && this.#giteaKeys) ended.push(...this.#giteaReconcileLocal(business)); } return ended; } @@ -1512,6 +1531,16 @@ export class Broker { this.#store.transaction(() => this.#agent(s)); if (a.action !== 'credential.mint' || 'spec_path' in a || 'spec_commit' in a) fail('invalid-request'); if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only'); + // Row 236 (#436b section 2): no network call at raise, so every check runs in the raise transaction. + if (a.grant?.method === 'gitea') { + if (!this.#giteaGrant) fail('gitea-not-configured'); + const g = this.#giteaGrant.giteaGrantCheck(a.grant, this.#now().slice(0, 10)); + return this.#store.transaction(() => { + const result = this.#dispatch(s, 'decision.raise', a, null, this.#giteaGrantBinding(s.business, g)); + this.#secretCheck(result); + return result; + }); + } if (!this.#hostGrant) fail('host-not-configured'); if (a.grant?.method === 'host') { const g = this.#hostGrant.hostGrantCheck(a.grant, this.#now().slice(0, 10)); @@ -1565,6 +1594,934 @@ export class Broker { : `\nThis makes a new key for ${g.agent} on ${g.host}.`); return { body, block }; } + // Row 236 (#1742, #436b r5 section 2): the agent's role and definition, if the agent is a role of the + // business whose definition declares gitea and which has identity and a publish grant (199 D8). A + // creative role gets no grant, whatever its definition declares (mos-1163). + #giteaRole(business, agent) { + const b = this.#business(business); + if (!Object.hasOwn(b.roles, agent)) fail('gitea-not-declared'); + const role = b.roles[agent]; + if (CREATIVE_DEFINITIONS.includes(role.definition)) fail('gitea-not-declared'); + const definition = Object.hasOwn(b.definitions ?? {}, role.definition) ? b.definitions[role.definition] : null; + if ( + !definition?.credentials?.some((c) => c.service === 'gitea') || + !role.identity?.gitea || + !Array.isArray(role.publish) || + role.publish.length === 0 + ) + fail('gitea-not-declared'); + return { b, role, definition }; + } + // Section 2, 199 D9 step 2: each target project is in publish and its remote is on the agent's Gitea + // and not the agent's own. Returns each project's upstream and the fork it gets. + #giteaTargets(b, role, projects) { + return projects.map((project) => { + const upstream = b.projects?.[project]?.remote; + if (!role.publish.includes(project) || !upstream) fail('remote-not-granted'); + const problem = forkProblem(upstream, role.identity); + if (problem) fail(problem); + return { project, upstream, fork: forkUrl(upstream, role.identity.gitea.user) }; + }); + } + // Section 2: the admin slot, on the agent's Gitea origin and not past its rotate-by date. + #giteaAdminMeta(business, url) { + const meta = this.#giteaGrant.adminMeta(business); + if (!meta || parseGiteaUrl(meta.url).url?.origin !== parseGiteaUrl(url).url?.origin) fail('grant-admin-missing'); + if (meta.rotateBy < this.#now().slice(0, 10)) fail('grant-admin-rotation-due'); + return meta; + } + // Section 2: the store checks and the raise binding. The caller owns the transaction. The chief + // can't shape the block: every line comes from the business, the slot and the store. + #giteaGrantBinding(business, g) { + const { b, role, definition } = this.#giteaRole(business, g.agent); + const targets = this.#giteaTargets(b, role, g.target.projects); + const meta = this.#giteaAdminMeta(business, role.identity.gitea.url); + const kind = this.#store.get('SELECT 1 FROM gitea_grants WHERE business=? AND agent=?', business, g.agent) + ? 'token rotation' + : this.#store.get('SELECT 1 FROM gitea_users WHERE business=? AND agent=?', business, g.agent) + ? 'existing stack-made user, new token' + : 'new Gitea user'; + const body = { + method: 'gitea', + agent: g.agent, + target: g.target, + rotateBy: g.rotateBy, + definitionSha256: this.#giteaGrant.definitionSha256(definition), + identitySha256: this.#giteaGrant.identitySha256(role, b.projects), + adminSha256: this.#giteaGrant.adminSha256(meta), + }; + const others = role.publish.filter((p) => !g.target.projects.includes(p)).map((p) => b.projects?.[p]?.remote ?? p); + const { gitea, email } = role.identity; + const block = + `\n\nGrant ${g.agent} a Gitea token: user ${gitea.user}, email ${email}, url ${gitea.url}.` + + `\nForks:` + + targets.map((t) => `\n${t.fork}`).join('') + + `\nScopes: ${['read:user', 'write:repository'].join(', ')}.` + + `\nRotate by ${g.rotateBy}.` + + `\nKind: ${kind}.` + + `\nThe token can write every repository this user can write. Step 5 checks that this is only its forks.` + + (others.length ? `\nIt may also reach this user's existing forks of:` + others.map((u) => `\n${u}`).join('') : ''); + return { body, block }; + } + // Section 4 step 1's checks after the spend. Throws the code that ends the grant. + #giteaRecheck(business, decision, g) { + const { b, role, definition } = this.#giteaRole(business, g.agent); + if (this.#giteaGrant.definitionSha256(definition) !== g.definitionSha256) fail('decision-mismatch'); + if (this.#giteaGrant.identitySha256(role, b.projects) !== g.identitySha256) fail('decision-mismatch'); + const meta = this.#giteaAdminMeta(business, role.identity.gitea.url); + if (this.#giteaGrant.adminSha256(meta) !== g.adminSha256) fail('decision-mismatch'); + const targets = this.#giteaTargets(b, role, g.target.projects); + if (this.#giteaRevokedSince(business, decision, g.agent)) fail('revoked'); + return { b, role, targets }; + } + // Section 4 step 1: a gitea grant.revoked of the agent recorded after the decision's raise. + #giteaRevokedSince(business, decision, agent) { + return Boolean( + this.#store.get( + `SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.method')='gitea' AND json_extract(body,'$.agent')=? AND seq > (SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='decision.raise' AND json_extract(body,'$.decision')=? ORDER BY seq LIMIT 1) LIMIT 1`, + business, agent, business, decision, + ), + ); + } + // Section 3d: every Gitea call writes grant.call before it and grant.result after it, each in its own + // transaction. `after` runs in the result's transaction, for a store write that goes with the answer. + #giteaLog(business, reason) { + return async (op, run, after = null) => { + const callId = randomUUID(); + this.#store.transaction(() => this.#grantEvent(business, { action: 'grant.call', callId, reason, method: 'gitea', op })); + const r = await run(); + this.#store.transaction(() => { + this.#grantEvent(business, { action: 'grant.result', callId, status: r.status }); + after?.(r); + }); + return r; + }; + } + // The grant.call records with this reason and no grant.result, listed in reconcile's message (3d). + #giteaUnclosedCalls(business, reason) { + return this.#store + .all( + `SELECT json_extract(c.body,'$.callId') AS callId, json_extract(c.body,'$.op') AS op FROM events c WHERE c.business=? AND ${GRANT_EVIDENCE.replace(/kind|actor_role|actor_run/g, 'c.$&')} AND json_extract(c.body,'$.action')='grant.call' AND json_extract(c.body,'$.method')='gitea' AND json_extract(c.body,'$.reason')=? AND NOT EXISTS (SELECT 1 FROM events r WHERE r.business=c.business AND ${GRANT_EVIDENCE.replace(/kind|actor_role|actor_run/g, 'r.$&')} AND json_extract(r.body,'$.action')='grant.result' AND json_extract(r.body,'$.callId')=json_extract(c.body,'$.callId')) ORDER BY c.seq`, + business, + reason, + ) + .map((c) => ({ callId: c.callId, op: c.op })); + } + // A grant's end, in the caller's transaction, and the message to the human and the chief unless + // `notify` is false (N9's restart end). The body names the decision grantDecision (section 7's spend rule). + #giteaFailed(business, decision, agent, code, extra = {}, notify = true) { + this.#grantEvent(business, { action: 'grant.failed', method: 'gitea', grantDecision: decision, agent, code, ...extra }, agent); + if (!notify) return; + this.#grantNotice( + business, + decision, + `Gitea grant to ${agent} (decision ${decision}) failed: ${code}.` + + (extra.repository ? ` It reaches ${extra.repository}.` : '') + + (extra.serviceLeft?.length ? ` Left at Gitea: ${extra.serviceLeft.join(', ')}. Run: mosaic grants sweep gitea` : '') + + (extra.unclosedCalls?.length ? ` Calls with no result: ${extra.unclosedCalls.map((c) => c.op).join(', ')}.` : ''), + ); + } + // Section 5: a delete by id or name with the admin token. A 404 counts as done (N9). Returns whether + // the token is gone. + async #giteaDelete(api, user, idOrName) { + const r = await api.tokenDelete(user, idOrName); + return r.status === 204 || r.status === 404; + } + // The admin api, after C1 to C4 and the rotate-by check, or null when either fails (an undo's and a + // revoke's "Gitea can't be reached"). + async #giteaAdminChecked(business, log) { + try { + const { meta, api } = this.#giteaGrant.admin(business, log); + if (meta.rotateBy < this.#now().slice(0, 10)) fail('grant-admin-rotation-due'); + await this.#giteaGrant.adminChecks(api, meta.account); + return { meta, api }; + } catch { + return null; + } + } + // Section 4 step 0, the synchronous half: approved, unspent, not held, and the claim. Throws the + // refusal; writes nothing. + #giteaBegin(business, decision) { + if (!this.#giteaGrant || !this.#giteaKeys) fail('gitea-not-configured'); + const g = this.#approvedGrant(business, decision); + if (g?.method !== 'gitea') fail('not-authorized'); + if (this.#spent(business, decision)) fail('decision-consumed'); + if (this.#giteaGrant.tokenUnknown(this.#store, business).has(g.agent)) fail('grant-token-unknown'); + let signal; + try { + signal = this.#giteaKeys.claim(business, g.agent); + } catch (e) { + fail(e instanceof BusError && e.code === 'host-busy' ? 'grant-busy' : 'gitea-failed'); + } + return { g, signal }; + } + // X1's start on the human's yes. A step 0 refusal other than not-authorized and decision-consumed is + // recorded and leaves the decision unspent. + #startGitea(business, decision) { + let start; + try { + start = this.#giteaBegin(business, decision); + } catch (e) { + const code = e instanceof BusError ? e.code : 'gitea-failed'; + if (code !== 'not-authorized' && code !== 'decision-consumed') { + try { + const agent = this.#raised(business, decision)?.grant?.agent ?? null; + this.#store.transaction(() => this.#giteaFailed(business, decision, agent, code)); + } catch {} + } + return Promise.resolve(code); + } + return this.#track(this.#runGitea(business, decision, start.g, start.signal)); + } + #track(promise) { + this.#giteaRuns.add(promise); + promise.finally(() => this.#giteaRuns.delete(promise)); + return promise; + } + // Trusted API, as runHostGrant is, and no verb reaches it: one decision's executor, to its end. + runGiteaGrant(business, decision) { + return this.#startGitea(business, decision); + } + // Trusted API for the tests: resolves once every Gitea executor and remote undo in flight has ended. + async giteaIdle() { + while (this.#giteaRuns.size) await Promise.allSettled([...this.#giteaRuns]); + } + // Section 4 steps 0 (the remote half) to 8. Never throws: the outcome is in the store and the + // notices, and the return value is 'applied', 'settle-pending' or the code grant.failed records. + async #runGitea(business, decision, g, signal) { + const log = this.#giteaLog(business, decision); + const agent = g.agent; + const stop = () => { + if (signal.aborted) fail('revoked'); + }; + try { + // Step 0: C1 to C4 and the slot's rotate-by date, before the spend. A refusal leaves it unspent. + let meta, api; + try { + ({ meta, api } = this.#giteaGrant.admin(business, log)); + if (meta.rotateBy < this.#now().slice(0, 10)) fail('grant-admin-rotation-due'); + await this.#giteaGrant.adminChecks(api, meta.account); + stop(); + } catch (e) { + const code = signal.aborted ? 'revoked' : e instanceof BusError ? e.code : 'gitea-failed'; + try { + this.#store.transaction(() => this.#giteaFailed(business, decision, agent, code)); + } catch {} + return code; + } + // Step 1, one transaction: the spend and the rechecks. A refusal rolls the spend back. + let checked; + try { + checked = this.#store.transaction(() => this.#giteaRecheck(business, decision, this.#consumeGrant(business, decision, 'gitea'))); + } catch (e) { + const code = e instanceof BusError ? e.code : 'gitea-failed'; + if (code !== 'not-authorized' && code !== 'decision-consumed') { + try { + this.#store.transaction(() => this.#giteaFailed(business, decision, agent, code)); + } catch {} + } + return code; + } + return await this.#giteaSteps(business, decision, g, signal, log, api, meta, checked, stop); + } finally { + this.#giteaKeys.release(business, agent, signal); + } + } + // Steps 2 to 8, after the spend. + async #giteaSteps(business, decision, g, signal, log, api, meta, { b, role, targets }, stop) { + const agent = g.agent; + const { identity } = role; + const user = identity.gitea.user; + const lower = (x) => (typeof x === 'string' ? x.toLowerCase() : null); + const step = (name, extra = {}) => + this.#store.transaction(() => this.#grantEvent(business, { action: 'grant.step', method: 'gitea', grantDecision: decision, agent, user, step: name, ...extra }, agent)); + let tokenId = null, + tokenName = null, + minting = false, + registered = false, + issuing = false, + committed = false, + previous = null; + try { + // Step 2. + const record = this.#store.get('SELECT * FROM gitea_users WHERE business=? AND agent=?', business, agent); + if (record && lower(record.user) !== lower(user)) fail('gitea-user-foreign'); + const found = await api.userGet(user); + stop(); + let userId; + if (found.status === 404) { + if (record) fail('gitea-user-foreign'); + const nonce = this.#giteaGrant.nonce(); + step('user', { nonce }); + const made = await api.userCreate( + { username: user, email: identity.email, full_name: `${identity.name} [mosaic ${nonce}]`, password: this.#giteaGrant.password(), must_change_password: false }, + (r) => { + if (r.status === 201 && giteaId(r.json?.id) && lower(r.json?.login) === lower(user)) + this.#store.run( + 'INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?)', + business, agent, user, r.json.id, nonce, decision, + ); + }, + ); + stop(); + if (made.status !== 201 || !giteaId(made.json?.id) || lower(made.json?.login) !== lower(user)) fail('gitea-failed'); + userId = made.json.id; + } else if (found.status === 200) { + if (!record || !giteaId(found.json?.id) || found.json.id !== record.id) fail('gitea-user-foreign'); + if (found.json.is_admin !== false) fail('gitea-user-admin'); + if (found.json.prohibit_login !== false) fail('gitea-user-disabled'); + userId = record.id; + } else fail('gitea-failed'); + // Step 3. + tokenName = this.#giteaGrant.tokenName(business, agent, decision); + step('mint', { tokenName }); + minting = true; + const minted = await api.tokenCreate(user, tokenName); + if (minted.status !== 201 || !giteaId(minted.json?.id) || typeof minted.json?.sha1 !== 'string') fail('gitea-failed'); + tokenId = minted.json.id; + const token = this.#giteaGrant.checkToken(minted.json.sha1); + step('minted', { tokenId }); + stop(); + // Step 4. + const seat = this.#giteaGrant.seat(meta.url, token, log); + const forks = []; + for (const t of targets) { + const { owner, repo } = parseGiteaUrl(t.upstream, { repo: true }); + const f = await seat.forkGet(user, repo); + stop(); + if (f.status === 200) { + if (lower(f.json?.owner?.login) !== lower(user) || lower(f.json?.parent?.full_name) !== lower(`${owner}/${repo}`)) fail('fork-foreign'); + } else if (f.status === 404) { + const made = await seat.fork(owner, repo); + stop(); + if (made.status !== 202 && made.status !== 201) fail('gitea-failed'); + } else fail('gitea-failed'); + forks.push(t.fork); + } + // Step 5, against every project in publish, not only the target. + const me = await seat.self(); + stop(); + if (me.status !== 200) fail('gitea-reach-unknown'); + if (lower(me.json?.login) !== lower(user) || me.json?.id !== userId) fail('gitea-user-foreign'); + const upstreams = new Set(); + for (const p of role.publish) { + const u = parseGiteaUrl(b.projects?.[p]?.remote, { repo: true }); + if (u.url) upstreams.add(lower(`${u.owner}/${u.repo}`)); + } + let seen = 0, + total = null, + wider = null; + for (let page = 1; ; page++) { + if (page > this.#giteaGrant.repoPages) fail('gitea-reach-unknown'); + const r = await seat.repos(page); + stop(); + if (r.status !== 200 || !Array.isArray(r.json) || r.total === null || (total !== null && r.total !== total)) fail('gitea-reach-unknown'); + total = r.total; + seen += r.json.length; + for (const repo of r.json) { + const parent = typeof repo?.parent?.full_name === 'string' ? lower(repo.parent.full_name) : null; + if (wider === null && (lower(repo?.owner?.login) !== lower(user) || !parent || !upstreams.has(parent))) + wider = typeof repo?.full_name === 'string' ? repo.full_name : 'unknown'; + } + if (r.json.length < this.#giteaGrant.repoPage) break; + } + if (seen !== total) fail('gitea-reach-unknown'); + if (wider !== null) { + const e = new BusError('gitea-reach-wider'); + e.repository = wider; + throw e; + } + // Step 6. Nothing from the abort check to the end of commit awaits. + const live = this.#store.get('SELECT * FROM gitea_grants WHERE business=? AND agent=?', business, agent); + previous = live ? { tokenId: live.token_id, tokenName: live.token_name, projects: JSON.parse(live.projects), rotateBy: live.rotate_by, decision: live.decision } : null; + step('commit', { previousTokenId: previous?.tokenId ?? null, tokenId, tokenName, previous }); + stop(); + const applied = (extra = {}) => { + this.#grantEvent(business, { action: 'grant.applied', method: 'gitea', grantDecision: decision, agent, user, userId, tokenId, forks, ...extra }, agent); + this.#grantNotice( + business, + decision, + `${agent} has a Gitea token as ${user} on ${meta.url} (decision ${decision}), forks: ${forks.join(', ')}.` + + (extra.serviceLeft?.length ? ` The previous token ${extra.serviceLeft.join(', ')} is still live. Run: mosaic grants sweep gitea` : ''), + ); + }; + const write = () => + this.#store.run( + 'INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?) ' + + 'ON CONFLICT(business,agent) DO UPDATE SET user=excluded.user,token_id=excluded.token_id,token_name=excluded.token_name,projects=excluded.projects,rotate_by=excluded.rotate_by,decision=excluded.decision', + business, agent, user, tokenId, tokenName, JSON.stringify(g.target.projects), g.rotateBy, decision, + ); + if (!this.#giteaKeys.known(business, agent)) { + this.#giteaKeys.register(business, agent, g.rotateBy); + registered = true; + } + issuing = true; + // A first grant's record and grant.applied commit together, so no restart finds one without the + // other. A rotation's record moves at step 8, with its grant.applied: until then the open commit + // step is what a restart settles, and what a revoke reads both ids from. + this.#giteaKeys.issue(business, agent, token, g.rotateBy, () => { + if (!previous) + this.#store.transaction(() => { + write(); + applied(); + }); + }); + committed = true; + if (!previous) return 'applied'; + // Step 7, after the new token is live. + const serviceLeft = (await this.#giteaDelete(api, user, previous.tokenId).catch(() => false)) ? [] : [previous.tokenId]; + // Step 8. + this.#store.transaction(() => { + write(); + applied(serviceLeft.length ? { serviceLeft } : {}); + }); + return 'applied'; + } catch (e) { + if (committed) { + // Live already: a late abort is an ordinary revoke's to handle. Only a store write failed, so the + // open commit step stays for the next start's settle. + return 'settle-pending'; + } + const code = signal.aborted ? 'revoked' : e instanceof BusError ? e.code : 'gitea-failed'; + // Section 5's table, rotation after the rename: the file may hold either token, so settle. + if (issuing && previous) return this.#giteaSettle(business, decision, agent, { user, tokenId, previousTokenId: previous.tokenId, previous, tokenName }, g, api, meta, log); + if (registered || issuing) { + try { + this.#giteaKeys.revoke(business, agent); + } catch {} + try { + this.#giteaKeys.unregister(business, agent); + } catch {} + } + const extra = e.repository ? { repository: e.repository } : {}; + if (minting) { + const what = tokenId ?? tokenName; + if (!(await this.#giteaDelete(api, user, what).catch(() => false))) extra.serviceLeft = [what]; + extra.user = user; + } + try { + this.#store.transaction(() => this.#giteaFailed(business, decision, agent, code, extra)); + } catch {} + return code; + } + } + // Section 5's settle: the store file's token, asked of Gitea (token-whoami). `s` is the commit step. + // Forward, back, lost and unknown each end the grant; anything else leaves it settle-pending, with + // nothing deleted. `api` is null when C1 to C4 failed, which is settle-pending too. + async #giteaSettle(business, decision, agent, s, g, api, meta, log) { + if (!api) return 'settle-pending'; + let who; + try { + who = await this.#giteaKeys.useFile(business, agent, async (token) => { + if (token === null) return { status: 401, id: null }; + const r = await this.#giteaGrant.seat(meta.url, token, log).whoami(); + return { status: r.status, id: giteaId(r.json?.id) ? r.json.id : null }; + }); + } catch { + return 'settle-pending'; + } + const user = s.user; + const record = (t) => + this.#store.run( + 'INSERT INTO gitea_grants(business,agent,user,token_id,token_name,projects,rotate_by,decision) VALUES(?,?,?,?,?,?,?,?) ' + + 'ON CONFLICT(business,agent) DO UPDATE SET user=excluded.user,token_id=excluded.token_id,token_name=excluded.token_name,projects=excluded.projects,rotate_by=excluded.rotate_by,decision=excluded.decision', + business, agent, user, t.tokenId, t.tokenName, JSON.stringify(t.projects), t.rotateBy, t.decision, + ); + if (who.status === 200 && who.id === s.tokenId) { + try { + this.#giteaKeys.reload(business, agent, g.rotateBy); + } catch { + return 'settle-pending'; + } + const serviceLeft = (await this.#giteaDelete(api, user, s.previousTokenId)) ? [] : [s.previousTokenId]; + this.#store.transaction(() => { + record({ tokenId: s.tokenId, tokenName: s.tokenName, projects: g.target.projects, rotateBy: g.rotateBy, decision }); + this.#grantEvent( + business, + { action: 'grant.applied', method: 'gitea', grantDecision: decision, agent, user, tokenId: s.tokenId, settled: 'forward', ...(serviceLeft.length ? { serviceLeft } : {}) }, + agent, + ); + this.#grantNotice(business, decision, `The Gitea token rotation of ${agent} (decision ${decision}) settled forward: the new token is live.`); + }); + return 'applied'; + } + if (who.status === 200 && who.id === s.previousTokenId) { + const serviceLeft = (await this.#giteaDelete(api, user, s.tokenId)) ? [] : [s.tokenId]; + this.#store.transaction(() => { + record(s.previous); + this.#giteaFailed(business, decision, agent, 'interrupted', { user, settled: 'back', ...(serviceLeft.length ? { serviceLeft } : {}) }); + }); + return 'interrupted'; + } + if (who.status === 401) { + const serviceLeft = []; + for (const id of [s.previousTokenId, s.tokenId]) if (!(await this.#giteaDelete(api, user, id))) serviceLeft.push(id); + try { + this.#giteaKeys.revoke(business, agent); + } catch {} + try { + this.#giteaKeys.unregister(business, agent); + } catch {} + this.#store.transaction(() => { + this.#store.run('DELETE FROM gitea_grants WHERE business=? AND agent=?', business, agent); + this.#giteaFailed(business, decision, agent, 'grant-lost', { user, ...(serviceLeft.length ? { serviceLeft } : {}) }); + }); + return 'grant-lost'; + } + if (who.status === 200 && who.id !== null) { + // A live token this grant didn't mint: memory drops it, and the file, the record and both tokens stay. + try { + this.#giteaKeys.hold(business, agent); + } catch {} + this.#store.transaction(() => this.#giteaFailed(business, decision, agent, 'grant-token-unknown', { user, tokenId: who.id })); + return 'grant-token-unknown'; + } + return 'settle-pending'; + } + // Section 5, a first grant's open commit at restart: the store file goes, whether or not a reference + // to it is known. A reference added only for this is taken away again. + #giteaUnlink(business, agent) { + try { + this.#giteaKeys.unlinkStore(business, agent); + return; + } catch (e) { + if (!(e instanceof BusError) || e.code !== 'credential-unknown') return; + } + try { + this.#giteaKeys.register(business, agent, '9999-12-31'); + try { + this.#giteaKeys.unlinkStore(business, agent); + } finally { + this.#giteaKeys.unregister(business, agent); + } + } catch {} + } + // Section 5's restart, the local part, from reconcileGrants before the socket opens. Returns the + // decisions it ended; the remote part waits in #giteaUndo for reconcileGitea. + #giteaReconcileLocal(business) { + const ended = []; + for (const o of this.#giteaGrant.openGrants(this.#store, business)) { + const g = this.#raised(business, o.decision).grant; + const s = o.step; + const rotation = s?.step === 'commit' && s.previousTokenId !== null; + // N9: a rotation's open commit with a grant.revoked after it. The revoke took both tokens. + if (rotation && o.revokedAfter) { + this.#store.transaction(() => this.#giteaFailed(business, o.decision, g.agent, 'revoked', { user: s.user }, false)); + ended.push(o.decision); + continue; + } + if (s?.step === 'commit' && !rotation) this.#giteaUnlink(business, g.agent); + if (s && ['user', 'mint', 'minted', 'commit'].includes(s.step)) { + let signal = null; + try { + signal = this.#giteaKeys.claim(business, g.agent); + } catch {} + this.#giteaUndo.push({ business, decision: o.decision, g, step: s, signal }); + continue; + } + this.#store.transaction(() => + this.#giteaFailed(business, o.decision, g.agent, 'interrupted', { unclosedCalls: this.#giteaUnclosedCalls(business, o.decision) }), + ); + ended.push(o.decision); + } + for (const decision of this.#openGrantDecisions(business, 'gitea')) { + this.#store.transaction(() => { + const g = this.#consumeGrant(business, decision, 'gitea'); + this.#giteaFailed(business, decision, g.agent, 'interrupted'); + }); + ended.push(decision); + } + for (const m of this.#giteaOpenRevokes(business)) this.#giteaRevokeUndo.push({ business, m }); + // Section 5's held row: Jason's message repeats at each start until a revoke. + for (const [agent, { decision }] of this.#giteaGrant.tokenUnknown(this.#store, business)) + this.#store.transaction(() => + this.#grantNotice(business, decision, `The Gitea token file of ${agent} still holds a token no grant minted (decision ${decision}). It stays unloaded until: mosaic credentials revoke ${agent} gitea`), + ); + return ended; + } + // Trusted API, after the socket opens: section 5's remote part, with C1 to C4 first. Never throws, and + // returns the decisions and revokes it ended. + async reconcileGitea() { + const run = (async () => { + const ended = []; + for (const u of this.#giteaUndo.splice(0)) { + const log = this.#giteaLog(u.business, u.decision); + const s = u.step; + try { + const admin = u.signal ? await this.#giteaAdminChecked(u.business, log) : null; + const unclosed = () => this.#giteaUnclosedCalls(u.business, u.decision); + if (s.step === 'commit' && s.previousTokenId !== null) { + const out = await this.#giteaSettle(u.business, u.decision, u.g.agent, s, u.g, admin?.api ?? null, admin?.meta, log); + if (out !== 'settle-pending') ended.push(u.decision); + continue; + } + if (s.step === 'user') { + let code = 'interrupted', + extra = {}, + adopt = null; + if (!admin) { + code = 'gitea-user-orphan'; + extra = { user: s.user }; + } else { + const r = await admin.api.userGet(s.user); + const j = r.json; + if ( + r.status === 200 && + giteaId(j?.id) && + typeof j.full_name === 'string' && + j.full_name.endsWith(` [mosaic ${s.nonce}]`) && + typeof j.login === 'string' && + j.login.toLowerCase() === s.user.toLowerCase() && + j.is_admin === false + ) { + adopt = j.id; + } else if (r.status !== 404) { + code = 'gitea-user-orphan'; + extra = { user: s.user }; + } + } + // An adopted user's record commits with the grant.failed, as every gitea table write goes with + // a grant.* event (T46). + this.#store.transaction(() => { + if (adopt !== null) + this.#store.run( + 'INSERT INTO gitea_users(business,agent,user,id,nonce,decision) VALUES(?,?,?,?,?,?) ON CONFLICT(business,agent) DO NOTHING', + u.business, u.g.agent, s.user, adopt, s.nonce, u.decision, + ); + this.#giteaFailed(u.business, u.decision, u.g.agent, code, { ...extra, unclosedCalls: unclosed() }); + }); + ended.push(u.decision); + continue; + } + // mint, minted, or a first grant's commit: the token goes, by id where one was recorded. + const what = s.tokenId ?? s.tokenName; + const gone = admin ? await this.#giteaDelete(admin.api, s.user, what).catch(() => false) : false; + this.#store.transaction(() => + this.#giteaFailed(u.business, u.decision, u.g.agent, 'interrupted', { user: s.user, ...(gone ? {} : { serviceLeft: [what] }), unclosedCalls: unclosed() }), + ); + ended.push(u.decision); + } catch { + } finally { + if (u.signal) this.#giteaKeys.release(u.business, u.g.agent, u.signal); + } + } + for (const { business, m } of this.#giteaRevokeUndo.splice(0)) { + try { + await this.#giteaCloseRevoke(business, m); + ended.push(m.revoke); + } catch {} + } + return ended; + })(); + return this.#track(run); + } + // Section 6 step 2, in one transaction: the record goes and grant.revoked records what step 3 deletes. + // That is the record's token, and both tokens of a rotation whose commit never settled (settle-pending, + // or grant-token-unknown, whose own unknown id stays for Jason). + #giteaRevokeLocal(business, agent, reason, by, fire) { + const revoke = randomUUID(); + return this.#store.transaction(() => { + const u = this.#store.get('SELECT user, id FROM gitea_users WHERE business=? AND agent=?', business, agent); + const live = this.#store.get('SELECT token_id FROM gitea_grants WHERE business=? AND agent=?', business, agent); + const tokens = new Set(live ? [live.token_id] : []); + const both = (s) => { + if (s?.step === 'commit' && s.previousTokenId !== null) for (const id of [s.previousTokenId, s.tokenId]) tokens.add(id); + }; + for (const o of this.#giteaGrant.openGrants(this.#store, business)) if (o.agent === agent) both(o.step); + const unknown = this.#giteaGrant.tokenUnknown(this.#store, business).get(agent); + if (unknown) { + const last = this.#store.get( + `SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.step' AND json_extract(body,'$.grantDecision')=? ORDER BY seq DESC LIMIT 1`, + business, + unknown.decision, + ); + both(last ? JSON.parse(last.body) : null); + } + this.#store.run('DELETE FROM gitea_grants WHERE business=? AND agent=?', business, agent); + const body = { + action: 'grant.revoked', method: 'gitea', revoke, agent, user: u?.user ?? null, userId: u?.id ?? null, + tokens: [...tokens], fire, granted: Boolean(live), reason, by, + }; + this.#grantEvent(business, body, agent); + return body; + }); + } + // The gitea grant.revoked bodies with no grant.revoke.closed of the same revoke id. + #giteaOpenRevokes(business) { + return this.#store + .all(`SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoked' AND json_extract(body,'$.method')='gitea' ORDER BY seq`, business) + .map((e) => JSON.parse(e.body)) + .filter( + (m) => + !this.#store.get( + `SELECT 1 FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.revoke.closed' AND json_extract(body,'$.revoke')=? LIMIT 1`, + business, + m.revoke, + ), + ); + } + // Section 6 step 3 and the fire's steps, after C1 to C4, then grant.revoke.closed with serviceLeft, a + // list (N10) of token ids, and "tokens" or "login" for a fire step that failed. + async #giteaCloseRevoke(business, m) { + const log = this.#giteaLog(business, m.revoke); + const left = []; + const admin = this.#giteaGrant && m.user ? await this.#giteaAdminChecked(business, log) : null; + for (const id of m.tokens) if (!admin || !(await this.#giteaDelete(admin.api, m.user, id).catch(() => false))) left.push(id); + let fireSkipped = null; + if (m.fire && m.user) { + if (!admin) left.push('tokens', 'login'); + else { + const r = await admin.api.userGet(m.user); + if (r.status === 404) fireSkipped = 'gitea-user-gone'; + else if (r.status !== 200 || r.json?.id !== m.userId) fireSkipped = r.status === 200 ? 'gitea-user-foreign' : null; + if (r.status !== 200 && r.status !== 404) left.push('tokens', 'login'); + else if (!fireSkipped) left.push(...(await this.#giteaFire(admin.api, m.user))); + } + } + const body = { + action: 'grant.revoke.closed', method: 'gitea', revoke: m.revoke, agent: m.agent, user: m.user, serviceLeft: left, + ...(fireSkipped ? { fireSkipped } : {}), by: m.by, + }; + this.#store.transaction(() => this.#grantEvent(business, body, m.agent)); + return body; + } + // Section 6, fire: every token the user has goes, page 1 again until it is empty, then prohibit_login + // (F10). Returns the steps that failed. + async #giteaFire(api, user) { + const left = []; + let clear = false; + for (let round = 0; round < this.#giteaGrant.repoPages && !clear; round++) { + const r = await api.tokenList(user, 1); + if (r.status !== 200 || !Array.isArray(r.json)) break; + if (r.json.length === 0) clear = true; + let gone = 0; + for (const t of r.json) if (giteaId(t?.id) && (await this.#giteaDelete(api, user, t.id))) gone++; + if (gone < r.json.length) break; + } + if (!clear) left.push('tokens'); + const p = await api.userPatch(user, { login_name: user, source_id: 0, prohibit_login: true }); + if (p.status !== 200) left.push('login'); + return left; + } + // The Gitea service left behind: the last grant.swept's `left`, and every serviceLeft recorded after + // that sweep began. Each entry is {agent, user, id}. + #giteaLeft(business) { + const swept = this.#store.get( + `SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.swept' AND json_extract(body,'$.method')='gitea' ORDER BY seq DESC LIMIT 1`, + business, + ); + const last = swept ? JSON.parse(swept.body) : null; + const since = last + ? (this.#store.get( + `SELECT seq FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.sweep' AND json_extract(body,'$.sweep')=?`, + business, + last.sweep, + )?.seq ?? 0) + : 0; + const left = new Map(); + const add = (agent, user, id) => left.set(JSON.stringify([agent, id]), { agent, user, id }); + for (const e of last?.left ?? []) add(e.agent, e.user, e.id); + for (const e of this.#store.all( + `SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.method')='gitea' AND json_extract(body,'$.action') IN ('grant.failed','grant.applied','grant.revoke.closed') AND json_type(body,'$.serviceLeft')='array' AND seq > ? ORDER BY seq`, + business, + since, + )) { + const b = JSON.parse(e.body); + for (const id of b.serviceLeft) add(b.agent, b.user, id); + } + return [...left.values()]; + } + // `mosaic grants sweep gitea`: open revokes close, settle-pending rotations settle again, and each + // left entry is retried. grant.sweep before, grant.swept after with what is still left. + async #sweepGitea(business, by) { + const sweep = randomUUID(); + this.#store.transaction(() => this.#grantEvent(business, { action: 'grant.sweep', method: 'gitea', sweep, by })); + const log = this.#giteaLog(business, sweep); + const left = this.#giteaLeft(business); + const closed = []; + for (const m of this.#giteaOpenRevokes(business)) { + if (this.#giteaRevokeUndo.some((u) => u.m.revoke === m.revoke)) continue; + const c = await this.#giteaCloseRevoke(business, m); + closed.push(m.revoke); + for (const id of c.serviceLeft) if (!left.some((e) => e.agent === m.agent && e.id === id)) left.push({ agent: m.agent, user: m.user, id, fresh: true }); + } + const settled = []; + for (const o of this.#giteaGrant.openGrants(this.#store, business)) { + if (o.step?.step !== 'commit' || o.step.previousTokenId === null || o.revokedAfter) continue; + let signal; + try { + signal = this.#giteaKeys.claim(business, o.agent); + } catch { + continue; + } + try { + const g = this.#raised(business, o.decision).grant; + const glog = this.#giteaLog(business, o.decision); + const admin = await this.#giteaAdminChecked(business, glog); + const out = await this.#giteaSettle(business, o.decision, o.agent, o.step, g, admin?.api ?? null, admin?.meta, glog); + settled.push({ decision: o.decision, outcome: out }); + } finally { + this.#giteaKeys.release(business, o.agent, signal); + } + } + const admin = await this.#giteaAdminChecked(business, log); + const still = []; + for (const e of left) { + if (e.fresh) { + still.push({ agent: e.agent, user: e.user, id: e.id }); + continue; + } + let ok = false; + if (admin && e.user) { + if (e.id === 'tokens' || e.id === 'login') { + const r = await this.#giteaFire(admin.api, e.user); + ok = !r.includes(e.id); + } else ok = await this.#giteaDelete(admin.api, e.user, e.id).catch(() => false); + } + if (!ok) still.push({ agent: e.agent, user: e.user, id: e.id }); + } + this.#store.transaction(() => this.#grantEvent(business, { action: 'grant.swept', method: 'gitea', sweep, left: still, settled, closed, by })); + return { left: still, settled, closed }; + } + // `mosaic grants status gitea`. + #giteaStatus(business) { + let admin = null; + try { + const meta = this.#giteaGrant.adminMeta(business); + admin = meta && { ...meta, rotationDue: meta.rotateBy < this.#now().slice(0, 10) }; + } catch (e) { + admin = { error: e instanceof BusError ? e.code : 'credential-file' }; + } + const lastAdmin = this.#store.get( + `SELECT body FROM events WHERE business=? AND ${GRANT_EVIDENCE} AND json_extract(body,'$.action')='grant.admin' AND json_extract(body,'$.method')='gitea' ORDER BY seq DESC LIMIT 1`, + business, + ); + const open = this.#giteaGrant.openGrants(this.#store, business); + return { + admin, + adminTokenLeft: lastAdmin ? (JSON.parse(lastAdmin.body).adminTokenLeft ?? null) : null, + users: this.#store.all('SELECT agent, user, id FROM gitea_users WHERE business=? ORDER BY agent', business), + grants: this.#store + .all('SELECT agent, user, token_id, token_name, projects, rotate_by, decision FROM gitea_grants WHERE business=? ORDER BY agent', business) + .map((r) => ({ agent: r.agent, user: r.user, tokenId: r.token_id, tokenName: r.token_name, projects: JSON.parse(r.projects), rotateBy: r.rotate_by, decision: r.decision })), + open: this.#openGrantDecisions(business, 'gitea').map((decision) => ({ decision, agent: this.#raised(business, decision).grant.agent, spentAtNextStart: true })), + running: open.filter((o) => !(o.step?.step === 'commit' && o.step.previousTokenId !== null)).map((o) => ({ decision: o.decision, agent: o.agent, step: o.step?.step ?? null })), + settlePending: open + .filter((o) => o.step?.step === 'commit' && o.step.previousTokenId !== null && !o.revokedAfter) + .map((o) => ({ decision: o.decision, agent: o.agent })), + tokenUnknown: [...this.#giteaGrant.tokenUnknown(this.#store, business)].map(([agent, { decision }]) => ({ agent, decision })), + serviceLeft: this.#giteaLeft(business), + revoking: this.#giteaOpenRevokes(business).map((m) => ({ revoke: m.revoke, agent: m.agent })), + }; + } + // Section 3d and 3e: the human's admin verbs. grant.admin records each, with no secret. + async #giteaAdmin(business, verb, a, by) { + const reason = `admin:${verb.slice('grants.admin.'.length)}:${by}`; + const log = this.#giteaLog(business, reason); + const today = this.#now().slice(0, 10); + if (verb === 'grants.admin.set') { + keys(a, ['service', 'url', 'account', 'rotateBy', 'token'], ['service', 'url', 'account', 'rotateBy', 'token']); + if (!parseGiteaUrl(a.url).url || !giteaName(a.account) || !calendarDay(a.rotateBy) || a.rotateBy <= today) fail('invalid-request'); + string(a.token, 16384); + const meta = await this.#giteaGrant.setAdmin(business, { url: a.url, account: a.account, rotateBy: a.rotateBy, token: a.token, setAt: this.#now() }, log); + this.#store.transaction(() => this.#grantEvent(business, { action: 'grant.admin', method: 'gitea', verb: 'set', ...meta, by })); + return meta; + } + if (verb === 'grants.admin.rotate') { + keys(a, ['service', 'rotateBy', 'finish'], ['service']); + if (a.finish !== undefined && a.finish !== true) fail('invalid-request'); + if (a.finish) { + if ('rotateBy' in a) fail('invalid-request'); + const status = this.#giteaStatus(business); + if (!status.adminTokenLeft || status.adminTokenLeft === 'unknown') fail('grant-admin-nothing-left'); + await this.#giteaGrant.finishRotate(business, status.adminTokenLeft, log); + this.#store.transaction(() => + this.#grantEvent(business, { action: 'grant.admin', method: 'gitea', verb: 'rotate-finish', deleted: status.adminTokenLeft, adminTokenLeft: null, by }), + ); + return { deleted: status.adminTokenLeft }; + } + if (!calendarDay(a.rotateBy) || a.rotateBy <= today) fail('invalid-request'); + const now = this.#now(); + const stamp = now.slice(0, 19).replace(/[-:]/g, '') + 'Z'; + const out = await this.#giteaGrant.rotateAdmin(business, { rotateBy: a.rotateBy, setAt: now, stamp }, log); + const voided = this.#openGrantDecisions(business, 'gitea'); + this.#store.transaction(() => + this.#grantEvent(business, { + action: 'grant.admin', method: 'gitea', verb: 'rotate', ...out.meta, tokenName: out.tokenName, adminTokenLeft: out.adminTokenLeft, voided: voided.length, by, + }), + ); + return { ...out.meta, tokenName: out.tokenName, adminTokenLeft: out.adminTokenLeft, voided }; + } + keys(a, ['service'], ['service']); + this.#giteaGrant.removeAdmin(business); + this.#store.transaction(() => this.#grantEvent(business, { action: 'grant.admin', method: 'gitea', verb: 'remove', adminTokenLeft: null, by })); + return { removed: true }; + } + // The grants verbs' callers. run and admin.* are the human's alone (section 3d, 4 step 0); sweep the + // human's or the tester's; revoke as hosts.revoke; status the human's, the tester's or the chief's. + #grantsCaller(s, verb) { + if (s.reader) fail('read-only'); + const humanOnly = verb === 'grants.run' || verb.startsWith('grants.admin.'); + if (s.tester) { + if (humanOnly) fail('human-required'); + if (verb === 'grants.revoke' && this.#business(s.business).vars?.['bus.tester'] !== true) fail('tester-dev-only'); + return 'tester'; + } + if (s.human) { + this.#human(s); + return s.human; + } + if (humanOnly || verb === 'grants.sweep') fail('human-required'); + this.#store.transaction(() => this.#agent(s)); + if (verb === 'grants.revoke') { + if (!this.#threaded(s.business, s.role)) fail('revoke-refused'); + return s.role; + } + if (this.#business(s.business).roles[s.role]?.definition !== STAFF_TO) fail('grant-chief-only'); + return s.role; + } + // Row 236: the grants verbs. Async, because each one may call Gitea. + async #requestGrants(s, verb, a) { + try { + const by = this.#grantsCaller(s, verb); + if (!this.#giteaGrant || !this.#giteaKeys) fail('gitea-not-configured'); + if (verb === 'grants.run') { + keys(a, ['decision'], ['decision']); + identifier(a.decision); + const { g, signal } = this.#giteaBegin(s.business, a.decision); + this.#track(this.#runGitea(s.business, a.decision, g, signal)); + return { decision: a.decision, agent: g.agent, started: true }; + } + if (a.service !== 'gitea') fail('invalid-request'); + if (verb === 'grants.status') { + keys(a, ['service'], ['service']); + return this.#giteaStatus(s.business); + } + if (verb === 'grants.sweep') { + keys(a, ['service'], ['service']); + return await this.#track(this.#sweepGitea(s.business, by)); + } + if (verb === 'grants.revoke') { + keys(a, ['service', 'agent', 'reason'], ['service', 'agent', 'reason']); + if (typeof a.agent !== 'string' || !ID_PATTERN.test(a.agent)) fail('invalid-request'); + string(a.reason, 500); + if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown'); + // Step 1: memory, the abort of a use or an executor in flight, the file. + try { + this.#giteaKeys.revoke(s.business, a.agent); + } catch (e) { + if (e instanceof BusError && e.code === 'credential-unknown') this.#giteaKeys.abortClaim(s.business, a.agent); + else throw e; + } + const m = this.#giteaRevokeLocal(s.business, a.agent, a.reason, by, false); + const closed = await this.#track(this.#giteaCloseRevoke(s.business, m)); + return { agent: a.agent, revoke: m.revoke, granted: m.granted, serviceLeft: closed.serviceLeft }; + } + return await this.#giteaAdmin(s.business, verb, a, by); + } catch (e) { + throw this.#refused(s, e, { verb, args: a }); + } + } #hostsCaller(s, verb) { if (s.reader) fail('read-only'); if (s.tester) { @@ -2561,6 +3518,7 @@ export class Broker { // Row 234 (#1740, r6 E1): a raise with `grant` runs its keyscan before the raise transaction. if (request.verb === 'decision.raise' && 'grant' in args) return this.#raiseGrant(s, structuredClone(args)); if (HOSTS_VERBS.includes(request.verb)) return this.#requestHosts(s, request.verb, structuredClone(args)); + if (GRANTS_VERBS.includes(request.verb)) return this.#requestGrants(s, request.verb, structuredClone(args)); if (request.verb === 'spec.read' || request.verb === 'record.write' || (request.verb === 'decision.raise' && ('spec_path' in args || 'spec_commit' in args))) return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) }); @@ -2695,6 +3653,18 @@ export class Broker { throw new BusError('outcome-unknown'); } this.#secretCheck(result); + // Row 236 (#436b section 6): a gitea revoke, or a fire, of an agent this stack made a Gitea user for + // takes the grant away too. Credentials has done step 1. Step 2 runs here; step 3 and the fire's + // steps run once this returns. + if ( + this.#giteaGrant && this.#giteaKeys && + (verb === 'credentials.delete' || (verb === 'credentials.revoke' && a.service === 'gitea')) && + this.#store.get('SELECT 1 FROM gitea_users WHERE business=? AND agent=?', s.business, a.agent) + ) { + const m = this.#giteaRevokeLocal(s.business, a.agent, a.reason, s.tester ? 'tester' : s.human, verb === 'credentials.delete'); + setImmediate(() => this.#track(this.#giteaCloseRevoke(s.business, m)).catch(() => {})); + return { ...result, giteaRevoke: m.revoke }; + } return result; } // Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the @@ -3476,6 +4446,7 @@ export class Broker { const grant = op === 'resolved' ? this.#approvedGrant(s.business, d.id) : null; if (grant?.method === 'host-enroll') setImmediate(() => this.#runEnrollment(s.business, d.id)); if (grant?.method === 'host') setImmediate(() => this.#runHostGrant(s.business, d.id)); + if (grant?.method === 'gitea') setImmediate(() => this.#startGitea(s.business, d.id)); if (s.human) this.#event( s, diff --git a/packages/bus/src/credentials.mjs b/packages/bus/src/credentials.mjs index 0556b382b..1f5336757 100644 --- a/packages/bus/src/credentials.mjs +++ b/packages/bus/src/credentials.mjs @@ -116,20 +116,26 @@ export class Credentials { #revoked = new Set(); // Row 210 (design D5, D9): instance:host. -> 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 /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' diff --git a/packages/bus/src/gitea-grant.mjs b/packages/bus/src/gitea-grant.mjs new file mode 100644 index 000000000..5ddae7167 --- /dev/null +++ b/packages/bus/src/gitea-grant.mjs @@ -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 /secrets/grants//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--`. 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; +} diff --git a/packages/bus/src/human-cli.mjs b/packages/bus/src/human-cli.mjs index 27b67fbf7..0855456be 100644 --- a/packages/bus/src/human-cli.mjs +++ b/packages/bus/src/human-cli.mjs @@ -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, diff --git a/packages/bus/src/migrations/3j-3k.sql b/packages/bus/src/migrations/3j-3k.sql new file mode 100644 index 000000000..47a04787d --- /dev/null +++ b/packages/bus/src/migrations/3j-3k.sql @@ -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; diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index b58d4dfd8..f4954127d 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -23,6 +23,7 @@ import { busBusiness } from './business.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'; @@ -131,7 +132,10 @@ export async function startBroker({ // Row 235 (r6 section 6, N1): a host. 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; @@ -253,6 +257,9 @@ export async function startBroker({ // Row 235 (#1741, r6 X4, X5): the grant executor's claim, register and issue, never use(). hostKeys: storeRoot ? grantKeys({ credentials, storeRoot }) : null, processing, + // Row 236 (#1742): the Gitea grant path. The admin slot is /secrets/grants//gitea. + giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null, + giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null, usage, limits, store, @@ -402,6 +409,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. diff --git a/packages/bus/src/server.mjs b/packages/bus/src/server.mjs index d508e3fd2..91f0d7633 100644 --- a/packages/bus/src/server.mjs +++ b/packages/bus/src/server.mjs @@ -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; @@ -140,6 +140,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; diff --git a/packages/bus/src/store.mjs b/packages/bus/src/store.mjs index 4e7280eec..82021d392 100644 --- a/packages/bus/src/store.mjs +++ b/packages/bus/src/store.mjs @@ -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', diff --git a/packages/bus/tests/account-migration.test.mjs b/packages/bus/tests/account-migration.test.mjs index 4cd14db44..1bb255ae5 100644 --- a/packages/bus/tests/account-migration.test.mjs +++ b/packages/bus/tests/account-migration.test.mjs @@ -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); }); diff --git a/packages/bus/tests/channel-proof.test.mjs b/packages/bus/tests/channel-proof.test.mjs index 2e467ce8f..fea6c0e49 100644 --- a/packages/bus/tests/channel-proof.test.mjs +++ b/packages/bus/tests/channel-proof.test.mjs @@ -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, diff --git a/packages/bus/tests/chief-of-staff.test.mjs b/packages/bus/tests/chief-of-staff.test.mjs index 3d1bf744f..cff0bd9c5 100644 --- a/packages/bus/tests/chief-of-staff.test.mjs +++ b/packages/bus/tests/chief-of-staff.test.mjs @@ -614,7 +614,7 @@ async function database3c(t) { let source = readFileSync(here('../src/store.mjs'), 'utf8'); const pinned = source .replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`) - .replace("export const SCHEMA_VERSION = '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'); @@ -638,7 +638,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a assert.throws(() => new Store(root), /schema-mismatch/); const store = new Store(root, { migrateSchema: true }); t.after(() => store.close()); - assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '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 }); diff --git a/packages/bus/tests/comment-migration.test.mjs b/packages/bus/tests/comment-migration.test.mjs index 161fdd63a..50b0f90d5 100644 --- a/packages/bus/tests/comment-migration.test.mjs +++ b/packages/bus/tests/comment-migration.test.mjs @@ -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); diff --git a/packages/bus/tests/fake-gitea-api.mjs b/packages/bus/tests/fake-gitea-api.mjs new file mode 100644 index 000000000..03addb397 --- /dev/null +++ b/packages/bus/tests/fake-gitea-api.mjs @@ -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; +} diff --git a/packages/bus/tests/fixtures/schema-3j.sql b/packages/bus/tests/fixtures/schema-3j.sql new file mode 100644 index 000000000..fe664c8e1 --- /dev/null +++ b/packages/bus/tests/fixtures/schema-3j.sql @@ -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; diff --git a/packages/bus/tests/gitea-grant-docker.test.mjs b/packages/bus/tests/gitea-grant-docker.test.mjs new file mode 100644 index 000000000..a31206a2e --- /dev/null +++ b/packages/bus/tests/gitea-grant-docker.test.mjs @@ -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: 'coder@example.test', 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'); +}); diff --git a/packages/bus/tests/gitea-grant.test.mjs b/packages/bus/tests/gitea-grant.test.mjs new file mode 100644 index 000000000..8fa203337 --- /dev/null +++ b/packages/bus/tests/gitea-grant.test.mjs @@ -0,0 +1,1061 @@ +// Row 236 (#1742, #436b r5 section 9): the Gitea grant executor, its restart, revoke, sweep and admin +// verbs, against the fake Gitea in fake-gitea-api.mjs on 127.0.0.1. Every token is a fake the fake made; +// no test reaches git.mosaicstack.dev or any provider, and no test creates a real account or token +// (Q188). Decisions a test needs outside the human's yes go in by SQL as fixtures. A token value is only +// ever compared, never printed: the checks use assert.ok on a boolean. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Broker, GRANT_EVIDENCE, writeGrantEvent } from '../src/broker.mjs'; +import { Credentials } from '../src/credentials.mjs'; +import { + SEAT_SCOPES, adminSha256, definitionSha256, giteaGrant, giteaKeys, giteaStart, identitySha256, openGiteaGrants, tokenName, tokenUnknown, versionAtLeast, +} from '../src/gitea-grant.mjs'; +import { Store } from '../src/store.mjs'; +import { fakeGiteaApi } from './fake-gitea-api.mjs'; +import { grantDecision } from './host-fixtures.mjs'; + +const AT = '2026-10-21T00:00:00.000Z'; +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 options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; +const none = { authority: { withinRole: ['message.send'], crossRole: [] } }; +const DEFINITIONS = { + 'chief-of-staff': { roleVersion: 2, credentials: [] }, + pm: { roleVersion: 2, credentials: [] }, + coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] }, + research: { roleVersion: 2, credentials: [{ service: 'gitea' }] }, +}; +async function until(fn, ms = 10000) { + const end = Date.now() + ms; + while (!fn()) { + if (Date.now() > end) throw new Error('timed out'); + await sleep(20); + } +} +const rejects = (p, code) => assert.rejects(p, (e) => (assert.equal(e.code, code), true)); + +function makeBusiness(url) { + return { + 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: 'coder@example.test', gitea: { url, user: USER } }, + publish: ['app', 'lib'], + }, + }, + definitions: structuredClone(DEFINITIONS), + projects: Object.fromEntries(['app', 'lib', 'old'].map((p) => [p, { remote: `${url}/acme/${p}` }])), + vars: { 'bus.tester': true }, + }; +} + +async function setup(t, opts = {}) { + const dir = mkdtempSync(join(tmpdir(), 'g236-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const gitea = await fakeGiteaApi(t, join(dir, 'tls'), opts.fake); + gitea.addUser('acme'); + for (const r of ['app', 'lib', 'old']) gitea.addRepo('acme', r); + const configDir = join(dir, 'cfg'); + mkdirSync(configDir, { mode: 0o700 }); + const storeRoot = join(configDir, 'secrets', 'agents'); + mkdirSync(join(dir, 'data'), { mode: 0o700 }); + 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 = makeBusiness(gitea.url); + opts.business?.(business); + const grantApi = giteaGrant({ configDir, ca: gitea.ca, timeoutMs: opts.timeoutMs ?? 5000 }); + const meta = { url: gitea.url, account: 'mosaic-grant', rotateBy: opts.adminRotateBy ?? dayFrom(60), setAt: AT }; + if (opts.slot !== false) grantApi.writeAdmin('demo', gitea.adminToken, meta); + const keys = giteaKeys({ credentials, storeRoot }); + const calls = []; + const broker = new Broker({ + store, + businesses: { demo: business }, + secretCheck: (value) => credentials.assertClean(value), + giteaGrant: grantApi, + giteaKeys: keys, + credentialVerbs: (id, verb, args, actor) => { + calls.push([verb, actor]); + if (verb === 'credentials.revoke' || verb === 'credentials.delete') { + try { + credentials.revoke('demo/' + args.agent, 'gitea'); + } catch {} + } + return { ok: true }; + }, + }); + const caps = {}; + for (const role of ['chief', 'pm']) { + caps[role] = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role }); + broker.request(caps[role], { verb: 'role.claim' }); + } + caps.human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + caps.tester = broker.bindTester({ business: 'demo' }); + const call = (who, verb, args = {}) => broker.request(caps[who], { verb, args }); + const raise = (grant = {}) => + call('chief', 'decision.raise', { + action: 'credential.mint', question: 'Grant coder Gitea?', options, recommendation: 'yes', blocking: false, + grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30), ...grant }, + }); + const grantEvents = () => + store + .all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`) + .map((e) => JSON.parse(e.body)); + const ended = (decision) => grantEvents().findLast((e) => e.grantDecision === decision && ['grant.applied', 'grant.failed'].includes(e.action)); + const approve = async (grant) => { + const d = await raise(grant); + call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); + await until(() => ended(d.id)); + await broker.giteaIdle(); + return { id: d.id, end: ended(d.id) }; + }; + const fixtureGrant = (over = {}, close) => { + const grant = { + method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30), + definitionSha256: definitionSha256(business.definitions.coder), + identitySha256: identitySha256(business.roles.coder, business.projects), + adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta), + ...over, + }; + return grantDecision(store, { grant, ...(close !== undefined ? { close } : {}) }); + }; + const tokenFile = join(storeRoot, 'demo', 'coder', 'gitea'); + const fileHolds = (sha1) => existsSync(tokenFile) && readFileSync(tokenFile, 'utf8') === sha1 + '\n'; + const record = () => store.get("SELECT * FROM gitea_grants WHERE business='demo' AND agent='coder'"); + const userRecord = () => store.get("SELECT * FROM gitea_users WHERE business='demo' AND agent='coder'"); + const state = () => credentials.status().find((e) => e.instance === 'demo/coder' && e.service === 'gitea')?.state; + const messages = (decision) => + store.all("SELECT to_role, body FROM messages WHERE business=? AND decision=? AND from_role='_bus' AND instr(body, ?) ORDER BY rowid", 'demo', decision, `(decision ${decision})`); + const userTokens = () => gitea.tokens(USER) ?? []; + // The newest token the fake made for the seat user: the one a grant minted last. + const seatToken = (id) => gitea.users.get(USER)?.tokens.find((x) => x.id === id) ?? null; + // No token the fake made shows up in an event, a message, a decision or a refusal. + const noLeak = () => { + const text = [ + ...store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body), + ...store.all("SELECT body FROM messages WHERE business='demo'").map((r) => r.body), + ...store.all('SELECT question FROM decisions').map((r) => r.question), + JSON.stringify(gitea.requests), + ].join('\n'); + assert.ok(gitea.issued.every((tok) => !text.includes(tok)), 'a token value reached the store or the request log'); + }; + return { + dir, gitea, configDir, storeRoot, store, credentials, business, grantApi, meta, keys, broker, caps, calls, + call, raise, approve, grantEvents, ended, fixtureGrant, tokenFile, fileHolds, record, userRecord, state, messages, userTokens, seatToken, noLeak, + }; +} + +// A decision the human approved and a killed bus child spent, with its grant.step events up to `steps`. +function interrupted(f, steps, over = {}) { + const id = f.fixtureGrant(over); + writeGrantEvent(f.store, AT, 'demo', { operation: 'grant.gitea', decision: id }, id); + for (const [step, extra = {}] of steps) + writeGrantEvent(f.store, AT, 'demo', { action: 'grant.step', method: 'gitea', grantDecision: id, agent: 'coder', user: USER, step, ...extra }, 'coder'); + return id; +} + +// An open rotation: a live first grant, then a spent fixture decision whose commit step names the live +// token as previous and `next` (a fake token minted here, never by a grant) as the new one. +async function openRotation(f) { + const first = await f.approve(); + assert.equal(first.end.action, 'grant.applied'); + const live = f.record(); + const next = f.gitea.addToken(USER, tokenName('demo', 'coder', 'rot'), SEAT_SCOPES); + const previous = { tokenId: live.token_id, tokenName: live.token_name, projects: JSON.parse(live.projects), rotateBy: live.rotate_by, decision: live.decision }; + const id = interrupted(f, [ + ['mint', { tokenName: next.name }], + ['minted', { tokenId: next.id }], + ['commit', { previousTokenId: live.token_id, tokenId: next.id, tokenName: next.name, previous }], + ]); + return { first, live, next, id }; +} + +// A restart as runtime.mjs does it, on the same store: giteaStart, Credentials with its references and +// held list, a new broker, reconcileGrants before the socket opens, then reconcileGitea. +async function restart(t, f, { business = f.business, timeoutMs = 5000 } = {}) { + const references = {}; + const { held } = giteaStart(f.store, f.storeRoot, { demo: business }, references); + const credentials = new Credentials({ references, storeRoot: f.storeRoot, dataRoot: join(f.dir, 'data'), held }); + t.after(() => credentials.close()); + const grantApi = giteaGrant({ configDir: f.configDir, ca: f.gitea.ca, timeoutMs }); + const broker = new Broker({ + store: f.store, + businesses: { demo: business }, + secretCheck: (value) => credentials.assertClean(value), + giteaGrant: grantApi, + giteaKeys: giteaKeys({ credentials, storeRoot: f.storeRoot }), + }); + const before = f.gitea.requests.length; + const now = broker.reconcileGrants(); + const later = await broker.reconcileGitea(); + await broker.giteaIdle(); + const state = credentials.status().find((e) => e.instance === 'demo/coder' && e.service === 'gitea')?.state; + const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + return { credentials, broker, held, now, later, state, human, requests: f.gitea.requests.slice(before) }; +} + +test('versionAtLeast compares numbers and refuses a prerelease of 1.27.0', () => { + for (const v of ['1.27.0', '1.27.1', 'v1.30.0', '1.27.0+build', '2.0.0']) assert.equal(versionAtLeast(v), true, v); + for (const v of ['1.26.4', '1.27.0-rc0', '1.9.0', '1.27', 'x', null, '1.27.0 ']) assert.equal(versionAtLeast(v), false, String(v)); +}); + +test('X-G1: a raise that fails a check refuses and stores nothing', async (t) => { + const f = await setup(t); + const decisions = () => f.store.get('SELECT count(*) AS n FROM decisions').n; + const n = decisions(); + const bad = [ + [{ extra: 1 }, 'invalid-request'], + [{ rotateBy: '2020-01-01' }, 'invalid-request'], + [{ rotateBy: '2026-02-30' }, 'invalid-request'], + [{ target: { projects: [] } }, 'invalid-request'], + [{ target: { projects: ['app', 'app'] } }, 'invalid-request'], + [{ target: { projects: ['app'], repo: 'x' } }, 'invalid-request'], + [{ agent: 'pm' }, 'gitea-not-declared'], + [{ agent: 'nobody' }, 'gitea-not-declared'], + [{ target: { projects: ['old'] } }, 'remote-not-granted'], + ]; + for (const [over, code] of bad) await rejects(f.raise(over), code); + // Only the chief raises a grant. + await rejects( + f.call('pm', 'decision.raise', { + action: 'credential.mint', question: 'q', options, recommendation: 'yes', blocking: false, + grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) }, + }), + 'grant-chief-only', + ); + assert.equal(decisions(), n); + assert.equal(f.gitea.requests.length, 0); +}); + +test('X-G1: a creative role, a role without identity and a missing or due slot refuse at raise', async (t) => { + const creative = await setup(t, { business: (b) => (b.roles.coder.definition = 'research') }); + await rejects(creative.raise(), 'gitea-not-declared'); + const bare = await setup(t, { business: (b) => delete b.roles.coder.identity }); + await rejects(bare.raise(), 'gitea-not-declared'); + const noSlot = await setup(t, { slot: false }); + await rejects(noSlot.raise(), 'grant-admin-missing'); + const due = await setup(t, { adminRotateBy: '2026-01-01' }); + await rejects(due.raise(), 'grant-admin-rotation-due'); + const elsewhere = await setup(t, { business: (b) => (b.projects.app.remote = 'https://example.test/acme/app') }); + await rejects(elsewhere.raise(), 'remote-host-mismatch'); + for (const f of [creative, bare, noSlot, due, elsewhere]) assert.equal(f.gitea.requests.length, 0); +}); + +test('X-G2: admin set refuses each failed check and writes nothing, then writes the slot once', async (t) => { + const f = await setup(t, { slot: false }); + const set = (token, over = {}) => + f.call('human', 'grants.admin.set', { service: 'gitea', url: f.gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), token, ...over }); + const slot = join(f.configDir, 'secrets', 'grants', 'demo', 'gitea'); + for (const v of ['1.26.4', '1.27.0-rc0', '1.9.0']) { + f.gitea.state.version = v; + await rejects(set(f.gitea.adminToken), 'gitea-too-old'); + } + f.gitea.state.version = '1.27.1'; + const plain = f.gitea.addUser('plain', { scopes: ['write:admin', 'write:repository', 'write:user'] }).token.sha1; + await rejects(set(plain, { account: 'plain' }), 'grant-admin-account'); + await rejects(set(f.gitea.adminToken, { account: 'someone-else' }), 'grant-admin-account'); + const narrow = f.gitea.addToken('mosaic-grant', 'narrow', ['write:admin', 'write:repository']).sha1; + await rejects(set(narrow), 'grant-admin-scope'); + const wide = f.gitea.addToken('mosaic-grant', 'wide', ['write:admin', 'write:repository', 'write:user', 'write:organization']).sha1; + await rejects(set(wide), 'grant-admin-scope'); + const reach = f.gitea.addRepo('acme', 'shared', { collaborators: ['mosaic-grant'] }); + await rejects(set(f.gitea.adminToken), 'grant-admin-not-dedicated'); + f.gitea.repos.splice(f.gitea.repos.indexOf(reach), 1); + await rejects(set(f.gitea.adminToken, { rotateBy: '2020-01-01' }), 'invalid-request'); + // The url is an instance origin over https (row 199's parser; a trailing slash is the same origin). + await rejects(set(f.gitea.adminToken, { url: f.gitea.url + '/gitea' }), 'invalid-request'); + await rejects(set(f.gitea.adminToken, { url: f.gitea.url.replace('https:', 'http:') }), 'invalid-request'); + await rejects(f.call('chief', 'grants.admin.set', { service: 'gitea', url: f.gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: f.gitea.adminToken }), 'human-required'); + await rejects(f.call('tester', 'grants.admin.set', { service: 'gitea', url: f.gitea.url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: f.gitea.adminToken }), 'human-required'); + assert.equal(existsSync(slot), false); + const meta = await set(f.gitea.adminToken); + assert.deepEqual(Object.keys(meta).sort(), ['account', 'rotateBy', 'setAt', 'url']); + assert.equal(statSync(join(slot, 'admin')).mode & 0o777, 0o600); + assert.equal(statSync(slot).mode & 0o777, 0o700); + assert.ok(readFileSync(join(slot, 'admin'), 'utf8') === f.gitea.adminToken + '\n'); + await rejects(set(f.gitea.adminToken), 'grant-admin-exists'); + const admin = f.grantEvents().filter((e) => e.action === 'grant.admin'); + assert.deepEqual(admin.map((e) => e.verb), ['set']); + f.noLeak(); +}); + +test('X-G3 to X-G8: an approve makes the user, mints the token, forks, checks reach and writes the file, record and grant.applied', async (t) => { + const f = await setup(t); + const d = await f.raise(); + const block = f.store.get('SELECT question FROM decisions WHERE id=?', d.id).question; + assert.match(block, new RegExp(`user ${USER}, email coder@example.test`)); + assert.match(block, /Scopes: read:user, write:repository\./); + assert.match(block, /Kind: new Gitea user\./); + assert.match(block, new RegExp(`It may also reach this user's existing forks of:\\n${f.gitea.url}/acme/lib`)); + f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); + await until(() => f.ended(d.id)); + await f.broker.giteaIdle(); + const end = f.ended(d.id); + assert.equal(end.action, 'grant.applied', JSON.stringify(end)); + const u = f.gitea.users.get(USER); + const userStep = f.grantEvents().find((e) => e.grantDecision === d.id && e.step === 'user'); + assert.match(userStep.nonce, /^[0-9a-f]{16}$/); + assert.equal(u.full_name, `Coder [mosaic ${userStep.nonce}]`); + assert.equal(u.email, 'coder@example.test'); + assert.equal(u.is_admin, false); + assert.deepEqual({ ...f.userRecord() }, { business: 'demo', agent: 'coder', user: USER, id: u.id, nonce: userStep.nonce, decision: d.id }); + assert.equal(u.tokens.length, 1); + const tok = u.tokens[0]; + assert.deepEqual(tok.scopes, [...SEAT_SCOPES]); + assert.equal(tok.name, tokenName('demo', 'coder', d.id)); + assert.deepEqual(end, { + action: 'grant.applied', method: 'gitea', grantDecision: d.id, agent: 'coder', user: USER, userId: u.id, tokenId: tok.id, + forks: [`${f.gitea.url}/${USER}/app`], + }); + const fork = f.gitea.repos.find((r) => r.owner === USER && r.name === 'app'); + assert.equal(fork.parent, 'acme/app'); + assert.equal(statSync(f.tokenFile).mode & 0o777, 0o600); + assert.ok(f.fileHolds(tok.sha1), 'the store file holds the minted token'); + assert.deepEqual({ ...f.record() }, { + business: 'demo', agent: 'coder', user: USER, token_id: tok.id, token_name: tok.name, projects: '["app"]', rotate_by: dayFrom(30), decision: d.id, + }); + assert.equal(f.state(), 'valid'); + const mine = f.grantEvents().filter((e) => e.grantDecision === d.id); + assert.deepEqual(mine.map((e) => e.action + (e.step ? ' ' + e.step : '')), ['grant.step user', 'grant.step mint', 'grant.step minted', 'grant.step commit', 'grant.applied']); + assert.deepEqual(mine.find((e) => e.step === 'commit'), { + action: 'grant.step', method: 'gitea', grantDecision: d.id, agent: 'coder', user: USER, step: 'commit', previousTokenId: null, tokenId: tok.id, tokenName: tok.name, previous: null, + }); + // X-G8: each call has its result, in order, and the ops are the section's. + const log = f.grantEvents().filter((e) => e.action === 'grant.call' || e.action === 'grant.result'); + const calls = log.filter((e) => e.action === 'grant.call'); + for (const c of calls) assert.equal(log.filter((e) => e.action === 'grant.result' && e.callId === c.callId).length, 1, c.op); + assert.deepEqual(calls.map((c) => c.op), [ + 'version', 'self', 'token-scopes', 'repos-check', 'user-get', 'user-create', 'token-create', 'fork-get', 'fork', 'seat-self', 'seat-repos', + ]); + assert.ok(calls.every((c) => c.reason === d.id && c.method === 'gitea')); + // Section 7: the consume record is the only grant body with a `decision` key. + assert.deepEqual(f.grantEvents().filter((e) => 'decision' in e).map((e) => e.operation), ['grant.gitea']); + assert.deepEqual(f.messages(d.id).map((m) => m.to_role), ['human', 'chief']); + // The token routes went with Basic auth, the rest with the token header; nothing as an unknown user. + for (const r of f.gitea.requests) { + if (r.path.includes('/tokens')) assert.equal(r.auth, 'basic', r.path); + else if (r.path !== '/api/v1/version') assert.equal(r.auth, 'token', r.path); + } + f.noLeak(); +}); + +test('X-G4: a user this stack did not make, an admin, a disabled user and a remade user each refuse', async (t) => { + const foreign = await setup(t); + foreign.gitea.addUser(USER); + let r = await foreign.approve(); + assert.equal(r.end.code, 'gitea-user-foreign'); + assert.equal(foreign.userRecord(), undefined); + assert.equal(foreign.gitea.calls().filter((c) => c.startsWith('POST')).length, 0); + + const f = await setup(t); + assert.equal((await f.approve()).end.action, 'grant.applied'); + const u = f.gitea.users.get(USER); + u.is_admin = true; + r = await f.approve(); + assert.equal(r.end.code, 'gitea-user-admin'); + u.is_admin = false; + u.prohibit_login = true; + r = await f.approve(); + assert.equal(r.end.code, 'gitea-user-disabled'); + u.prohibit_login = false; + const tokenCalls = () => f.gitea.calls().filter((c) => c.includes('/tokens')).length; + const before = tokenCalls(); + f.gitea.removeUser(USER); + f.gitea.addUser(USER); + r = await f.approve(); + assert.equal(r.end.code, 'gitea-user-foreign'); + // A case variant of the name, remade: Gitea matches the login without case, the id differs. + f.gitea.removeUser(USER); + f.gitea.addUser(USER.toUpperCase()); + r = await f.approve(); + assert.equal(r.end.code, 'gitea-user-foreign'); + // None of these minted a token or made a token call, and the live grant stands. + assert.equal(tokenCalls(), before); + assert.equal(f.state(), 'valid'); + assert.equal(f.grantEvents().filter((e) => e.step === 'mint').length, 1); + f.noLeak(); +}); + +test('X-G5: a failure after the mint deletes the token, unloads nothing it did not load and leaves no file', async (t) => { + const f = await setup(t); + const off = f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/forks'), { status: 500, json: {} }); + const r = await f.approve(); + off(); + assert.equal(r.end.code, 'gitea-failed'); + assert.equal(r.end.serviceLeft, undefined); + assert.equal(r.end.user, USER); + assert.deepEqual(f.userTokens(), []); + assert.equal(existsSync(f.tokenFile), false); + assert.equal(f.record(), undefined); + assert.equal(f.state(), undefined); + // The user stays recorded; the next grant reuses it. + assert.ok(f.userRecord()); + const again = await f.approve(); + assert.equal(again.end.action, 'grant.applied'); + assert.equal(f.gitea.calls().filter((c) => c === 'POST /api/v1/admin/users').length, 1); +}); + +test('X-G5: a token delete that fails is left at Gitea, shown by status and taken by sweep', async (t) => { + const f = await setup(t); + const offFork = f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/forks'), { status: 500, json: {} }); + const offDelete = f.gitea.rule((e) => e.method === 'DELETE', { status: 500, json: {} }); + const r = await f.approve(); + offFork(); + offDelete(); + assert.equal(r.end.code, 'gitea-failed'); + const id = f.userTokens()[0].id; + assert.deepEqual(r.end.serviceLeft, [id]); + assert.match(f.messages(r.id)[0].body, new RegExp(`Left at Gitea: ${id}\\. Run: mosaic grants sweep gitea`)); + const status = await f.call('human', 'grants.status', { service: 'gitea' }); + assert.deepEqual(status.serviceLeft, [{ agent: 'coder', user: USER, id }]); + const swept = await f.call('tester', 'grants.sweep', { service: 'gitea' }); + assert.deepEqual(swept.left, []); + assert.deepEqual(f.userTokens(), []); + assert.deepEqual((await f.call('human', 'grants.status', { service: 'gitea' })).serviceLeft, []); + await rejects(f.call('chief', 'grants.sweep', { service: 'gitea' }), 'human-required'); +}); + +test('X-G5: a mint that fails deletes by name, and a refusal at step 0 leaves the decision unspent', async (t) => { + const f = await setup(t); + const off = f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/tokens'), { status: 500, json: {} }); + const r = await f.approve(); + off(); + assert.equal(r.end.code, 'gitea-failed'); + assert.ok(f.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${tokenName('demo', 'coder', r.id)}`)); + // Step 0: the admin token's scope changed after set. + f.gitea.admin.tokens[0].scopes = ['write:admin']; + const s0 = await f.approve(); + assert.equal(s0.end.code, 'grant-admin-scope'); + assert.equal(f.grantEvents().some((e) => e.operation === 'grant.gitea' && e.decision === s0.id), false); + f.gitea.admin.tokens[0].scopes = ['write:admin', 'write:repository', 'write:user']; + // The human runs it again once the slot is right. + const run = await f.call('human', 'grants.run', { decision: s0.id }); + assert.deepEqual(run, { decision: s0.id, agent: 'coder', started: true }); + await f.broker.giteaIdle(); + assert.equal(f.grantEvents().filter((e) => e.grantDecision === s0.id).at(-1).action, 'grant.applied'); +}); + +test('X-G5: an agent held for an unknown token is refused at step 0 with no Gitea call', async (t) => { + const f = await setup(t); + const held = f.fixtureGrant(); + writeGrantEvent(f.store, AT, 'demo', { action: 'grant.failed', method: 'gitea', grantDecision: held, agent: 'coder', code: 'grant-token-unknown', tokenId: 7 }, 'coder'); + const r = await f.approve(); + assert.equal(r.end.code, 'grant-token-unknown'); + assert.equal(f.gitea.requests.length, 0); + await rejects(f.call('human', 'grants.run', { decision: r.id }), 'grant-token-unknown'); +}); + +test('X-G5 and X-G9 (step 7): a rotation whose old-token delete fails stays applied with the old id left', async (t) => { + const f = await setup(t); + const first = await f.approve(); + const oldId = f.record().token_id; + const off = f.gitea.rule((e) => e.method === 'DELETE' && e.path.endsWith(`/tokens/${oldId}`), { status: 500, json: {} }); + const second = await f.approve(); + off(); + assert.equal(second.end.action, 'grant.applied'); + assert.deepEqual(second.end.serviceLeft, [oldId]); + assert.equal(f.record().decision, second.id); + assert.notEqual(f.record().token_id, oldId); + assert.match(f.messages(second.id)[0].body, new RegExp(`The previous token ${oldId} is still live`)); + const swept = await f.call('human', 'grants.sweep', { service: 'gitea' }); + assert.deepEqual(swept.left, []); + assert.deepEqual(f.userTokens().map((x) => x.id), [f.record().token_id]); + assert.notEqual(first.id, second.id); +}); + +test('X-G10: in a rotation the store file holds the new token before the old one is deleted', async (t) => { + const f = await setup(t); + await f.approve(); + const oldId = f.record().token_id; + let seen = null; + f.gitea.rule( + (e) => e.method === 'DELETE' && e.path.endsWith(`/tokens/${oldId}`), + () => { + const newest = f.gitea.users.get(USER).tokens.at(-1); + seen = newest.id !== oldId && f.fileHolds(newest.sha1); + return { pass: true }; + }, + ); + const r = await f.approve(); + assert.equal(r.end.action, 'grant.applied'); + assert.equal(seen, true); + const commit = f.grantEvents().find((e) => e.grantDecision === r.id && e.step === 'commit'); + assert.equal(commit.previousTokenId, oldId); + assert.equal(commit.previous.tokenId, oldId); + assert.deepEqual(f.userTokens().map((x) => x.id), [r.end.tokenId]); + f.noLeak(); +}); + +test('X-G6: a rotation cut before the rename settles back at once, and the old token stays live', async (t) => { + const f = await setup(t); + await f.approve(); + const live = f.record(); + // The store's tmp path is a directory, so the issue fails before its rename. + mkdirSync(f.tokenFile + '.tmp'); + const r = await f.approve(); + rmSync(f.tokenFile + '.tmp', { recursive: true }); + assert.equal(r.end.code, 'interrupted'); + assert.equal(r.end.settled, 'back'); + assert.deepEqual({ ...f.record() }, { ...live }); + assert.deepEqual(f.userTokens().map((x) => x.id), [live.token_id]); + assert.ok(f.fileHolds(f.seatToken(live.token_id).sha1)); + assert.equal(f.state(), 'valid'); +}); + +test('X-G6: a rotation whose step 8 write fails is settle-pending, and a restart settles it forward', async (t) => { + const f = await setup(t); + await f.approve(); + const oldId = f.record().token_id; + f.store.run("CREATE TEMP TRIGGER g236_fail BEFORE UPDATE ON gitea_grants BEGIN SELECT RAISE(ABORT, 'test fault'); END"); + const d = await f.raise(); + f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); + await until(() => f.grantEvents().some((e) => e.grantDecision === d.id && e.step === 'commit')); + await f.broker.giteaIdle(); + f.store.run('DROP TRIGGER g236_fail'); + assert.equal(f.ended(d.id), undefined); + const status = await f.call('human', 'grants.status', { service: 'gitea' }); + assert.deepEqual(status.settlePending, [{ decision: d.id, agent: 'coder' }]); + const newId = f.grantEvents().find((e) => e.grantDecision === d.id && e.step === 'commit').tokenId; + const r = await restart(t, f); + assert.deepEqual(r.held, []); + const end = f.ended(d.id); + assert.equal(end.action, 'grant.applied'); + assert.equal(end.settled, 'forward'); + assert.equal(end.serviceLeft, undefined); + assert.equal(f.record().token_id, newId); + assert.equal(f.record().decision, d.id); + assert.equal(r.state, 'valid'); + assert.deepEqual(f.userTokens().map((x) => x.id), [newId]); + assert.notEqual(newId, oldId); +}); + +test('X-G6: a restart settles an open rotation back when the file still holds the old token', async (t) => { + const f = await setup(t); + const { live, next, id } = await openRotation(f); + const r = await restart(t, f); + const end = f.ended(id); + assert.equal(end.code, 'interrupted'); + assert.equal(end.settled, 'back'); + assert.equal(f.record().token_id, live.token_id); + assert.equal(f.record().decision, live.decision); + assert.deepEqual(f.userTokens().map((x) => x.id), [live.token_id]); + assert.equal(f.seatToken(next.id), null); + assert.equal(r.state, 'valid'); +}); + +test('X-G6: a restart settles an open rotation forward when the file holds the new token', async (t) => { + const f = await setup(t); + const { live, next, id } = await openRotation(f); + writeFileSync(f.tokenFile, next.sha1 + '\n'); + const r = await restart(t, f); + const end = f.ended(id); + assert.equal(end.action, 'grant.applied'); + assert.equal(end.settled, 'forward'); + assert.equal(f.record().token_id, next.id); + assert.equal(f.record().decision, id); + assert.deepEqual(f.userTokens().map((x) => x.id), [next.id]); + assert.equal(r.state, 'valid'); + assert.notEqual(live.token_id, next.id); +}); + +test('X-G6: a 401 on the file token loses the grant: both tokens go, the record goes and memory unloads', async (t) => { + const f = await setup(t); + const { live, id } = await openRotation(f); + const u = f.gitea.users.get(USER); + u.tokens.splice(u.tokens.findIndex((x) => x.id === live.token_id), 1); + const r = await restart(t, f); + const end = f.ended(id); + assert.equal(end.code, 'grant-lost'); + assert.equal(end.serviceLeft, undefined); + assert.equal(f.record(), undefined); + assert.deepEqual(f.userTokens(), []); + assert.equal(existsSync(f.tokenFile), false); + assert.equal(r.state, undefined); + assert.deepEqual(f.messages(id).map((m) => m.to_role), ['human', 'chief']); +}); + +test('X-G6: a 403, 404, 500, a hang and a dropped socket each leave the rotation settle-pending with nothing deleted', async (t) => { + for (const answer of [{ status: 403, json: {} }, { status: 404, json: {} }, { status: 500, json: {} }, { hang: true }, { drop: true }]) { + const f = await setup(t); + const { live, next, id } = await openRotation(f); + f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/token' && e.as === USER, answer); + const r = await restart(t, f, { timeoutMs: 400 }); + assert.equal(f.ended(id), undefined, JSON.stringify(answer)); + assert.deepEqual(f.userTokens().map((x) => x.id).sort(), [live.token_id, next.id].sort()); + assert.equal(r.requests.filter((q) => q.method === 'DELETE').length, 0); + assert.equal(f.record().token_id, live.token_id); + assert.deepEqual(openGiteaGrants(f.store, 'demo').map((o) => o.decision), [id]); + } +}); + +test('X-G6: a file token no grant minted is held; the next restart says so again; a revoke clears it and takes both ids', async (t) => { + const f = await setup(t); + const { live, next, id } = await openRotation(f); + const stray = f.gitea.addToken(USER, 'stray', SEAT_SCOPES); + writeFileSync(f.tokenFile, stray.sha1 + '\n'); + const r1 = await restart(t, f); + const end = f.ended(id); + assert.equal(end.code, 'grant-token-unknown'); + assert.equal(end.tokenId, stray.id); + assert.equal(r1.state, 'held'); + assert.ok(existsSync(f.tokenFile)); + assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], ['coder']); + const before = f.messages(id).length; + const r2 = await restart(t, f); + assert.deepEqual(r2.held, ['demo/coder:gitea']); + assert.equal(r2.state, 'held'); + assert.equal(r2.requests.length, 0); + assert.equal(f.messages(id).length, before + 2); + assert.match(f.messages(id).at(-1).body, /still holds a token no grant minted/); + // The revoke deletes the live token and both ids of the last commit step; the stray one stays for Jason. + const out = await r2.broker.request(r2.human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'test' } }); + assert.deepEqual(out.serviceLeft, []); + const revoked = f.grantEvents().filter((e) => e.action === 'grant.revoked').at(-1); + assert.deepEqual(revoked.tokens.sort(), [live.token_id, next.id].sort()); + assert.deepEqual(f.userTokens().map((x) => x.id), [stray.id]); + assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], []); + const r3 = await restart(t, f); + assert.deepEqual(r3.held, []); +}); + +test('N9: a revoke of an open rotation, then a restart: no settle, no Gitea call and no message', async (t) => { + const f = await setup(t); + const { live, next, id } = await openRotation(f); + const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' }); + assert.deepEqual(out.serviceLeft, []); + assert.deepEqual(f.grantEvents().find((e) => e.action === 'grant.revoked').tokens.sort(), [live.token_id, next.id].sort()); + assert.deepEqual(f.userTokens(), []); + const status = await f.call('human', 'grants.status', { service: 'gitea' }); + assert.deepEqual(status.settlePending, []); + const before = f.messages(id).length; + const r = await restart(t, f); + assert.deepEqual(r.requests, []); + const end = f.ended(id); + assert.equal(end.code, 'revoked'); + assert.equal(end.settled, undefined); + assert.equal(f.messages(id).length, before); + assert.equal(f.record(), undefined); + assert.equal(r.state, undefined); +}); + +test('N9: a 404 on a delete by id counts as done', async (t) => { + const f = await setup(t); + const { next } = await openRotation(f); + const u = f.gitea.users.get(USER); + u.tokens.splice(u.tokens.findIndex((x) => x.id === next.id), 1); + const out = await f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'test' }); + assert.deepEqual(out.serviceLeft, []); + assert.ok(f.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${next.id}`)); +}); + +test('X-G6: a restart undoes each step a killed child left, and spends an approved unspent decision', async (t) => { + const f = await setup(t); + // user: a user made with the nonce is adopted; one without it is an orphan; none is interrupted. + const nonce = 'aaaaaaaaaaaaaaaa'; + f.gitea.addUser(USER, { full_name: `Coder [mosaic ${nonce}]` }); + const u1 = interrupted(f, [['user', { nonce }]]); + let r = await restart(t, f); + assert.equal(f.ended(u1).code, 'interrupted'); + assert.equal(f.userRecord().id, f.gitea.users.get(USER).id); + assert.equal(f.userRecord().nonce, nonce); + + const g = await setup(t); + g.gitea.addUser(USER, { full_name: 'Someone' }); + const u2 = interrupted(g, [['user', { nonce }]]); + await restart(t, g); + assert.equal(g.ended(u2).code, 'gitea-user-orphan'); + assert.equal(g.userRecord(), undefined); + + const h = await setup(t); + const u3 = interrupted(h, [['user', { nonce }]]); + await restart(t, h); + assert.equal(h.ended(u3).code, 'interrupted'); + assert.equal(h.userRecord(), undefined); + + // mint by name, minted by id, a first grant's commit by id with its file unlinked. + const k = await setup(t); + k.gitea.addUser(USER); + const named = k.gitea.addToken(USER, tokenName('demo', 'coder', 'x1'), SEAT_SCOPES); + const m1 = interrupted(k, [['mint', { tokenName: named.name }]]); + // X-G8: a call the child wrote before it was killed, with no result, is listed in the end. + writeGrantEvent(k.store, AT, 'demo', { action: 'grant.call', callId: 'c-open', reason: m1, method: 'gitea', op: 'token-create' }, 'coder'); + r = await restart(t, k); + assert.equal(k.ended(m1).code, 'interrupted'); + assert.deepEqual(k.ended(m1).unclosedCalls, [{ callId: 'c-open', op: 'token-create' }]); + assert.match(k.messages(m1)[0].body, /Calls with no result: token-create\./); + assert.deepEqual(k.userTokens(), []); + + // One executor per agent (grant-busy), so a killed child leaves one open grant per agent: its own store. + const m = await setup(t); + m.gitea.addUser(USER); + const byId = m.gitea.addToken(USER, 'x2', SEAT_SCOPES); + const m2 = interrupted(m, [['mint', { tokenName: 'x2' }], ['minted', { tokenId: byId.id }]]); + await restart(t, m); + assert.equal(m.ended(m2).code, 'interrupted'); + assert.equal(m.ended(m2).serviceLeft, undefined); + assert.ok(m.gitea.calls().includes(`DELETE /api/v1/users/${USER}/tokens/${byId.id}`)); + assert.deepEqual(m.userTokens(), []); + + const c = await setup(t); + c.gitea.addUser(USER); + const tok = c.gitea.addToken(USER, 'x3', SEAT_SCOPES); + const c1 = interrupted(c, [['commit', { previousTokenId: null, tokenId: tok.id, tokenName: 'x3', previous: null }]]); + mkdirSync(join(c.storeRoot, 'demo', 'coder'), { recursive: true, mode: 0o700 }); + writeFileSync(c.tokenFile, tok.sha1 + '\n', { mode: 0o600 }); + r = await restart(t, c); + assert.deepEqual(r.held, ['demo/coder:gitea']); + assert.equal(c.ended(c1).code, 'interrupted'); + assert.equal(existsSync(c.tokenFile), false); + assert.deepEqual(c.userTokens(), []); + assert.equal(r.state, undefined); + + // An approved decision no child spent is spent at start and ends interrupted, with no Gitea call. + const o = await setup(t); + const open = o.fixtureGrant(); + const status = await o.call('human', 'grants.status', { service: 'gitea' }); + assert.deepEqual(status.open, [{ decision: open, agent: 'coder', spentAtNextStart: true }]); + r = await restart(t, o); + assert.deepEqual(r.now, [open]); + assert.equal(o.ended(open).code, 'interrupted'); + assert.ok(o.grantEvents().some((e) => e.operation === 'grant.gitea' && e.decision === open)); + assert.deepEqual(r.requests, []); +}); + +test('X-G6: a restart with no admin slot leaves the user an orphan and the token left', async (t) => { + const f = await setup(t); + const id = interrupted(f, [['mint', { tokenName: 'x1' }], ['minted', { tokenId: 41 }]]); + rmSync(join(f.configDir, 'secrets', 'grants'), { recursive: true }); + await restart(t, f); + assert.deepEqual(f.ended(id).serviceLeft, [41]); + const u = interrupted(f, [['user', { nonce: 'bbbbbbbbbbbbbbbb' }]]); + await restart(t, f); + assert.equal(f.ended(u).code, 'gitea-user-orphan'); +}); + +test('X-G7: a change to the definition, identity, publish remotes or admin slot after the raise ends decision-mismatch', async (t) => { + // The broker holds its own copy of the business, so each change is a binding the raise saw before it + // (as row 234's G9 does): the decision's hash differs from what step 1 hashes now. + const f = await setup(t); + const b = f.business; + const meta = f.grantApi.adminMeta('demo'); + for (const over of [ + { definitionSha256: definitionSha256({ ...b.definitions.coder, roleVersion: 3 }) }, + { identitySha256: identitySha256({ ...b.roles.coder, identity: { ...b.roles.coder.identity, email: 'other@example.test' } }, b.projects) }, + { identitySha256: identitySha256(b.roles.coder, { ...b.projects, lib: { remote: `${f.gitea.url}/acme/lib2` } }) }, + { adminSha256: adminSha256({ ...meta, setAt: '2026-01-01T00:00:00.000Z' }) }, + ]) { + const id = f.fixtureGrant(over); + await f.call('human', 'grants.run', { decision: id }); + await f.broker.giteaIdle(); + assert.equal(f.ended(id).code, 'decision-mismatch', JSON.stringify(over)); + // The spend rolled back with the recheck. + assert.equal(f.grantEvents().some((e) => e.operation === 'grant.gitea' && e.decision === id), false); + } + assert.equal(f.gitea.calls().some((c) => c.includes('/admin/users') || c.includes('/tokens')), false); +}); + +test('X-G7 and X-G9: an admin rotate voids open decisions, and rotate --finish deletes what was left', async (t) => { + const f = await setup(t); + const open = f.fixtureGrant(); + const offDelete = f.gitea.rule((e) => e.method === 'DELETE' && e.path.endsWith('/tokens/mosaic-grant-bootstrap'), { status: 500, json: {} }); + const out = await f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: dayFrom(90) }); + offDelete(); + assert.deepEqual(out.voided, [open]); + assert.equal(out.adminTokenLeft, 'mosaic-grant-bootstrap'); + assert.match(out.tokenName, /^mosaic-grant-\d{8}T\d{6}Z$/); + assert.equal(f.grantApi.adminMeta('demo').rotateBy, dayFrom(90)); + const successor = f.gitea.admin.tokens.find((x) => x.name === out.tokenName); + assert.ok(readFileSync(join(f.configDir, 'secrets', 'grants', 'demo', 'gitea', 'admin'), 'utf8') === successor.sha1 + '\n'); + assert.equal((await f.call('human', 'grants.status', { service: 'gitea' })).adminTokenLeft, 'mosaic-grant-bootstrap'); + await f.call('human', 'grants.run', { decision: open }); + await f.broker.giteaIdle(); + assert.equal(f.ended(open).code, 'decision-mismatch'); + const done = await f.call('human', 'grants.admin.rotate', { service: 'gitea', finish: true }); + assert.deepEqual(done, { deleted: 'mosaic-grant-bootstrap' }); + assert.deepEqual(f.gitea.admin.tokens.map((x) => x.name), [out.tokenName]); + await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', finish: true }), 'grant-admin-nothing-left'); + assert.deepEqual(f.grantEvents().filter((e) => e.action === 'grant.admin').map((e) => e.verb), ['rotate', 'rotate-finish']); + f.noLeak(); +}); + +test('X-G9: a rotate whose name is taken refuses and writes nothing; remove takes the slot away', async (t) => { + const f = await setup(t); + const slotFile = join(f.configDir, 'secrets', 'grants', 'demo', 'gitea', 'admin'); + const before = readFileSync(slotFile); + const now = Date.now(); + for (let s = -1; s < 6; s++) { + const stamp = new Date(now + s * 1000).toISOString().slice(0, 19).replace(/[-:]/g, '') + 'Z'; + f.gitea.addToken('mosaic-grant', `mosaic-grant-${stamp}`, ['write:admin', 'write:repository', 'write:user']); + } + await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: dayFrom(90) }), 'grant-admin-name-taken'); + assert.ok(readFileSync(slotFile).equals(before)); + await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: '2020-01-01' }), 'invalid-request'); + await rejects(f.call('human', 'grants.admin.rotate', { service: 'gitea', rotateBy: dayFrom(90), finish: true }), 'invalid-request'); + await rejects(f.call('chief', 'grants.admin.remove', { service: 'gitea' }), 'human-required'); + assert.deepEqual(await f.call('human', 'grants.admin.remove', { service: 'gitea' }), { removed: true }); + assert.equal(existsSync(join(f.configDir, 'secrets', 'grants', 'demo', 'gitea')), false); + await rejects(f.call('human', 'grants.admin.remove', { service: 'gitea' }), 'grant-admin-missing'); + await rejects(f.raise(), 'grant-admin-missing'); +}); + +test('X-G9: a slot past its rotate-by date refuses a run at step 0, leaving the decision unspent', async (t) => { + const f = await setup(t); + const open = f.fixtureGrant(); + f.grantApi.writeAdmin('demo', f.gitea.adminToken, { ...f.meta, rotateBy: '2026-01-01' }); + const status = await f.call('human', 'grants.status', { service: 'gitea' }); + assert.equal(status.admin.rotationDue, true); + await f.call('human', 'grants.run', { decision: open }); + await f.broker.giteaIdle(); + assert.equal(f.ended(open).code, 'grant-admin-rotation-due'); + assert.equal(f.gitea.requests.length, 0); + const before = await f.call('human', 'grants.status', { service: 'gitea' }); + assert.deepEqual(before.open, [{ decision: open, agent: 'coder', spentAtNextStart: true }]); + // X-G11: a restart spends it as interrupted, and the human's run then refuses decision-consumed. + const r = await restart(t, f); + assert.deepEqual(r.now, [open]); + assert.equal(f.ended(open).code, 'interrupted'); + await rejects(r.broker.request(r.human, { verb: 'grants.run', args: { decision: open } }), 'decision-consumed'); +}); + +test('X-G11: run refuses every caller but the human, an unapproved, a spent and a busy decision', async (t) => { + const f = await setup(t); + const open = f.fixtureGrant(); + await rejects(f.call('chief', 'grants.run', { decision: open }), 'human-required'); + await rejects(f.call('tester', 'grants.run', { decision: open }), 'human-required'); + const unapproved = f.fixtureGrant({}, null); + await rejects(f.call('human', 'grants.run', { decision: unapproved }), 'not-authorized'); + const tester = f.fixtureGrant({}, { op: 'resolved', by: 'tester', choice: 'yes', via: 'broker' }); + await rejects(f.call('human', 'grants.run', { decision: tester }), 'not-authorized'); + const signal = f.keys.claim('demo', 'coder'); + await rejects(f.call('human', 'grants.run', { decision: open }), 'grant-busy'); + f.keys.release('demo', 'coder', signal); + await f.call('human', 'grants.run', { decision: open }); + await f.broker.giteaIdle(); + assert.equal(f.ended(open).action, 'grant.applied'); + await rejects(f.call('human', 'grants.run', { decision: open }), 'decision-consumed'); + await rejects(f.call('human', 'grants.run', { decision: open, extra: 1 }), 'invalid-request'); + // status: the human, the tester and the chief; no other role. + for (const who of ['human', 'tester', 'chief']) assert.ok(await f.call(who, 'grants.status', { service: 'gitea' })); + await rejects(f.call('pm', 'grants.status', { service: 'gitea' }), 'grant-chief-only'); + assert.deepEqual(f.grantEvents().filter((e) => 'decision' in e).map((e) => e.operation), ['grant.gitea']); +}); + +test('X-G11: a revoke while the executor runs aborts it and the grant ends revoked', async (t) => { + const f = await setup(t); + let release; + const held = new Promise((r) => (release = r)); + f.gitea.rule((e) => e.method === 'POST' && e.path.endsWith('/forks'), async () => { + await held; + return { pass: true }; + }); + const d = await f.raise(); + f.call('human', 'decision.resolve', { id: d.id, choice: 'yes' }); + await until(() => f.gitea.calls().some((c) => c.endsWith('/forks'))); + const revoke = f.call('human', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'stop' }); + release(); + await revoke; + await until(() => f.ended(d.id)); + await f.broker.giteaIdle(); + assert.equal(f.ended(d.id).code, 'revoked'); + assert.equal(f.record(), undefined); + assert.deepEqual(f.userTokens(), []); +}); + +test('X-G12: a token that reaches wider than its forks fails with the repository named', async (t) => { + const collab = await setup(t); + collab.gitea.addRepo('other', 'secret', { collaborators: [USER] }); + let r = await collab.approve(); + assert.equal(r.end.code, 'gitea-reach-wider'); + assert.equal(r.end.repository, 'other/secret'); + assert.match(collab.messages(r.id)[0].body, /It reaches other\/secret\./); + assert.deepEqual(collab.userTokens(), []); + assert.equal(existsSync(collab.tokenFile), false); + + // A fork of a project that left publish. + const f = await setup(t); + assert.equal((await f.approve()).end.action, 'grant.applied'); + f.gitea.addRepo(USER, 'old', { parent: 'acme/old' }); + r = await f.approve(); + assert.equal(r.end.code, 'gitea-reach-wider'); + assert.equal(r.end.repository, `${USER}/old`); + assert.equal(f.state(), 'valid'); + + // An old fork of a publish project outside target.projects passes. + const ok = await setup(t); + assert.equal((await ok.approve()).end.action, 'grant.applied'); + ok.gitea.addRepo(USER, 'lib', { parent: 'acme/lib' }); + r = await ok.approve(); + assert.equal(r.end.action, 'grant.applied'); + assert.deepEqual(ok.userTokens().map((x) => x.id), [r.end.tokenId]); +}); + +test('X-G12: reach is paged by X-Total-Count and a count that does not add up refuses', async (t) => { + const f = await setup(t); + assert.equal((await f.approve()).end.action, 'grant.applied'); + for (let i = 0; i < 51; i++) f.gitea.addRepo(USER, `app${i}`, { parent: 'acme/app' }); + const before = f.gitea.requests.length; + let r = await f.approve(); + assert.equal(r.end.action, 'grant.applied'); + const pages = f.gitea.requests.slice(before).filter((q) => q.path.startsWith('/api/v1/user/repos') && q.as === USER).map((q) => q.path); + assert.deepEqual(pages, ['/api/v1/user/repos?limit=50&page=1', '/api/v1/user/repos?limit=50&page=2']); + const off = f.gitea.rule((e) => e.path.startsWith('/api/v1/user/repos') && e.as === USER, { status: 200, json: [], total: 5 }); + r = await f.approve(); + off(); + assert.equal(r.end.code, 'gitea-reach-unknown'); + const noTotal = f.gitea.rule((e) => e.path.startsWith('/api/v1/user/repos') && e.as === USER, { status: 200, json: [] }); + r = await f.approve(); + noTotal(); + assert.equal(r.end.code, 'gitea-reach-unknown'); +}); + +test('X-G13: an agent cannot record a grant event, and the trigger refuses one from any writer but the grant writer', async (t) => { + const f = await setup(t); + for (const body of [ + { action: 'grant.applied', method: 'gitea', agent: 'coder' }, + { action: 'grant.revoked', method: 'gitea', agent: 'coder' }, + { operation: 'grant.gitea', decision: randomUUID() }, + ]) + assert.throws(() => f.broker.recordEvent(f.caps.chief, { kind: 'action.allowed', body }), (e) => (assert.equal(e.code, 'reserved-event'), true)); + assert.throws(() => + f.store.run( + "INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',NULL,NULL,'coder',?)", + randomUUID(), AT, 'demo', JSON.stringify({ action: 'grant.step', method: 'gitea' }), + ), + /only the grant writer/); + // The grant writer takes grant.* bodies only. + assert.throws(() => writeGrantEvent(f.store, AT, 'demo', { action: 'host.sudo', host: 'web1' }, 'web1'), /grant event shape/); +}); + +test('X-G14: credentials delete fires the user: tokens go, login is prohibited, and grant.revoke.closed records it', async (t) => { + const f = await setup(t); + await f.approve(); + f.gitea.addToken(USER, 'extra', SEAT_SCOPES); + const out = f.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' }); + assert.match(out.giteaRevoke, /^[0-9a-f-]{36}$/); + await until(() => f.grantEvents().some((e) => e.action === 'grant.revoke.closed')); + await f.broker.giteaIdle(); + const closed = f.grantEvents().find((e) => e.action === 'grant.revoke.closed'); + assert.deepEqual(closed.serviceLeft, []); + assert.equal(closed.revoke, out.giteaRevoke); + assert.deepEqual(f.userTokens(), []); + assert.equal(f.gitea.users.get(USER).prohibit_login, true); + assert.equal(f.record(), undefined); + const revoked = f.grantEvents().find((e) => e.action === 'grant.revoked'); + assert.equal(revoked.fire, true); + assert.equal(revoked.by, 'jason'); + // A plain revoke through the credentials verb takes the grant but leaves the user. + const g = await setup(t); + await g.approve(); + g.call('human', 'credentials.revoke', { agent: 'coder', service: 'gitea', reason: 'r' }); + await until(() => g.grantEvents().some((e) => e.action === 'grant.revoke.closed')); + await g.broker.giteaIdle(); + assert.equal(g.gitea.users.get(USER).prohibit_login, false); + assert.deepEqual(g.userTokens(), []); + f.noLeak(); +}); + +test('X-G14: a fire whose token delete or login PATCH fails lands it in serviceLeft', async (t) => { + const f = await setup(t); + await f.approve(); + const extra = f.gitea.addToken(USER, 'extra', SEAT_SCOPES); + const offDelete = f.gitea.rule((e) => e.method === 'DELETE' && e.path.endsWith(`/tokens/${extra.id}`), { status: 500, json: {} }); + const offPatch = f.gitea.rule((e) => e.method === 'PATCH', { status: 500, json: {} }); + f.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' }); + await until(() => f.grantEvents().some((e) => e.action === 'grant.revoke.closed')); + await f.broker.giteaIdle(); + offDelete(); + offPatch(); + const closed = f.grantEvents().find((e) => e.action === 'grant.revoke.closed'); + assert.deepEqual(closed.serviceLeft, ['tokens', 'login']); + assert.equal(f.gitea.users.get(USER).prohibit_login, false); + assert.deepEqual(f.userTokens().map((x) => x.id), [extra.id]); +}); + +test('X-G14: a fire whose user is gone or remade skips the fire steps and says why', async (t) => { + const f = await setup(t); + await f.approve(); + f.gitea.removeUser(USER); + f.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' }); + await until(() => f.grantEvents().some((e) => e.action === 'grant.revoke.closed')); + await f.broker.giteaIdle(); + assert.equal(f.grantEvents().find((e) => e.action === 'grant.revoke.closed').fireSkipped, 'gitea-user-gone'); + const g = await setup(t); + await g.approve(); + g.gitea.removeUser(USER); + g.gitea.addUser(USER); + g.call('human', 'credentials.delete', { agent: 'coder', reason: 'fired' }); + await until(() => g.grantEvents().some((e) => e.action === 'grant.revoke.closed')); + await g.broker.giteaIdle(); + assert.equal(g.grantEvents().find((e) => e.action === 'grant.revoke.closed').fireSkipped, 'gitea-user-foreign'); + assert.equal(g.gitea.users.get(USER).prohibit_login, false); +}); + +test('X-G14: a revoke Gitea cannot reach stays open, and a restart closes it', async (t) => { + const f = await setup(t); + await f.approve(); + const tokenId = f.record().token_id; + const off = f.gitea.rule(() => true, { status: 500, json: {} }); + const out = await f.call('tester', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }); + assert.deepEqual(out.serviceLeft, [tokenId]); + off(); + const status = await f.call('human', 'grants.status', { service: 'gitea' }); + assert.deepEqual(status.serviceLeft, [{ agent: 'coder', user: USER, id: tokenId }]); + await f.call('human', 'grants.sweep', { service: 'gitea' }); + assert.deepEqual(f.userTokens(), []); + await rejects(f.call('pm', 'grants.revoke', { service: 'gitea', agent: 'coder', reason: 'r' }), 'revoke-refused'); +}); + +test('X-G15: look-alike commit steps outside the grant writer are ignored at start, by revoke and by status', async (t) => { + const f = await setup(t); + const first = await f.approve(); + const live = f.record(); + const fake = { action: 'grant.step', method: 'gitea', grantDecision: first.id, agent: 'coder', user: USER, step: 'commit', previousTokenId: 999, tokenId: 998, tokenName: 'x', previous: null }; + f.store.run( + "INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.refused',NULL,'grant','coder',?)", + randomUUID(), AT, 'demo', JSON.stringify(fake), + ); + f.store.run( + "INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'human.input',NULL,NULL,'coder',?)", + randomUUID(), AT, 'demo', JSON.stringify({ ...fake, action: 'grant.failed', code: 'grant-token-unknown' }), + ); + assert.deepEqual(openGiteaGrants(f.store, 'demo'), []); + assert.deepEqual([...tokenUnknown(f.store, 'demo').keys()], []); + const r = await restart(t, f); + assert.deepEqual(r.held, []); + assert.deepEqual(r.requests, []); + assert.equal(r.state, 'valid'); + const out = await r.broker.request(r.human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'r' } }); + assert.deepEqual(out.serviceLeft, []); + assert.deepEqual(f.grantEvents().find((e) => e.action === 'grant.revoked').tokens, [live.token_id]); +}); + +test('status lists the slot, users, grants and nothing secret, and every grants verb needs service gitea', async (t) => { + const f = await setup(t); + await f.approve(); + const s = await f.call('chief', 'grants.status', { service: 'gitea' }); + assert.deepEqual(s.admin, { ...f.meta, rotationDue: false }); + assert.equal(s.adminTokenLeft, null); + assert.deepEqual(s.users.map((u) => u.agent), ['coder']); + assert.deepEqual(s.grants.map((g) => [g.agent, g.projects]), [['coder', ['app']]]); + for (const k of ['open', 'running', 'settlePending', 'tokenUnknown', 'serviceLeft', 'revoking']) assert.deepEqual(s[k], [], k); + await rejects(f.call('human', 'grants.status', { service: 'host' }), 'invalid-request'); + await rejects(f.call('human', 'grants.sweep', {}), 'invalid-request'); + const bare = new Broker({ store: f.store, businesses: { demo: f.business } }); + const human = bare.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + await rejects(bare.request(human, { verb: 'grants.status', args: { service: 'gitea' } }), 'gitea-not-configured'); + f.noLeak(); +}); diff --git a/packages/bus/tests/gitea-migration.test.mjs b/packages/bus/tests/gitea-migration.test.mjs new file mode 100644 index 000000000..1d930d1a6 --- /dev/null +++ b/packages/bus/tests/gitea-migration.test.mjs @@ -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'"); +}); diff --git a/packages/bus/tests/grant-migration.test.mjs b/packages/bus/tests/grant-migration.test.mjs index 2715c1d62..ee2071079 100644 --- a/packages/bus/tests/grant-migration.test.mjs +++ b/packages/bus/tests/grant-migration.test.mjs @@ -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); }); diff --git a/packages/bus/tests/name-migration.test.mjs b/packages/bus/tests/name-migration.test.mjs index e8e660c87..92b4cc4b3 100644 --- a/packages/bus/tests/name-migration.test.mjs +++ b/packages/bus/tests/name-migration.test.mjs @@ -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) }); diff --git a/packages/bus/tests/schema.test.mjs b/packages/bus/tests/schema.test.mjs index e295c0b09..81513de4b 100644 --- a/packages/bus/tests/schema.test.mjs +++ b/packages/bus/tests/schema.test.mjs @@ -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); }); diff --git a/packages/bus/tests/spec-migration.test.mjs b/packages/bus/tests/spec-migration.test.mjs index 8f7fd1ac6..5e3e68979 100644 --- a/packages/bus/tests/spec-migration.test.mjs +++ b/packages/bus/tests/spec-migration.test.mjs @@ -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'), []); diff --git a/packages/bus/tests/thread-migration.test.mjs b/packages/bus/tests/thread-migration.test.mjs index e4c0e49c8..5c2f9e775 100644 --- a/packages/bus/tests/thread-migration.test.mjs +++ b/packages/bus/tests/thread-migration.test.mjs @@ -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')); }); diff --git a/packages/bus/tests/usage-migration.test.mjs b/packages/bus/tests/usage-migration.test.mjs index 02c5aa05a..17320f0a1 100644 --- a/packages/bus/tests/usage-migration.test.mjs +++ b/packages/bus/tests/usage-migration.test.mjs @@ -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'); diff --git a/packages/cli/README.md b/packages/cli/README.md index 7a609984e..c36d354ec 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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) ``` diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 1b7329285..dbff1700d 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -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} @@ -632,6 +634,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`, `business instance` and (row 194) `business veto` here. if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps)); if (verb === "talk") return talk(rest, io, deps); diff --git a/packages/cli/src/grants.mjs b/packages/cli/src/grants.mjs new file mode 100644 index 000000000..23318dd1b --- /dev/null +++ b/packages/cli/src/grants.mjs @@ -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); + } +} diff --git a/packages/cli/tests/grants.test.mjs b/packages/cli/tests/grants.test.mjs new file mode 100644 index 000000000..c89602b58 --- /dev/null +++ b/packages/cli/tests/grants.test.mjs @@ -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 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', diff --git a/scripts/mosaic b/scripts/mosaic index 633251dbe..5ea85cfbd 100755 --- a/scripts/mosaic +++ b/scripts/mosaic @@ -41,6 +41,6 @@ if [ "${1:-}" = library ] && [ "${2:-}" = survey ]; then exec node "$REPO/packages/library/src/survey.mjs" "$@" fi case "${1:-}" in - halt|resume|processing|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" "$@" ;; + halt|resume|processing|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" "$@"