Files
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

469 lines
25 KiB
JavaScript

// Row 236 (#1742, #436b design r5 at 1be535aca): the trusted half of the Gitea grant path, run in the bus
// child. The broker gets it as `giteaGrant` and holds every store write and every step's order. This module
// holds what touches the network or a secret file: the HTTPS calls to Gitea (each made through the
// broker's `log`, which writes grant.call before and grant.result after), the admin slot (section 3c), the
// binding's shape check (section 2), the admin checks C1 to C4 (section 3a), the start pre-load of held
// pairs (section 5) and `giteaKeys`, the broker's narrow handle on Credentials.
//
// The admin token lives at <configDir>/secrets/grants/<business>/gitea/admin, 0600 in 0700 dirs, with
// admin.json next to it ({url, account, rotateBy, setAt}, no secret). Both are written tmp, fsync, rename,
// as row 199's issue writes. Neither value reaches an event, a message or a log.
import { request as httpsRequest } from 'node:https';
import { createHash, randomBytes } from 'node:crypto';
import {
closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmdirSync, unlinkSync, writeSync,
} from 'node:fs';
import { join } from 'node:path';
import { BusError, GRANT_EVIDENCE } from './broker.mjs';
import { day, privateDir } from './credentials.mjs';
import { definitionSha256, grantTag } from './host-grant.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
import { forkUrl, giteaName, ID_PATTERN, parseGiteaUrl, CREATIVE_DEFINITIONS } from '../../organization/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) => {
const library = Object.fromEntries((role.publish ?? []).filter((p) => projects?.[p]?.kind === 'library')
.map((p) => [p, CREATIVE_DEFINITIONS.includes(role.definition) ? 'write' : 'read']));
return sha256({
identity: role.identity ?? null,
publish: role.publish ?? [],
remotes: Object.fromEntries((role.publish ?? []).map((p) => [p, projects?.[p]?.remote ?? null])),
// C2-21: bind kind and level, preserving every legacy non-library binding byte for byte.
...(Object.keys(library).length ? { library } : {}),
});
};
// Section 2: over the admin slot's metadata, never the token.
export const adminSha256 = (meta) => sha256({ url: meta.url, account: meta.account, setAt: meta.setAt });
// Step 3: `mosaic-<tag>-<decision>`. grantTag already starts with "mosaic-".
export const tokenName = (business, agent, decision) => `${grantTag(business, agent, 'gitea')}-${decision}`;
export { definitionSha256 };
// Section 3d: one HTTPS call. Never rejects: no answer (refused, reset, TLS failure, timeout, a body over
// the cap) is status 0. A redirect is returned as its status, never followed. `auth` is {token} for the
// `token` header or {basic: [account, token]} (F1). The result is {status, json, total}, total from
// X-Total-Count; it holds no header and no request detail.
export function send({ url, method, path, auth, body, ca, timeoutMs = CALL_MS }) {
return new Promise((resolve) => {
let done = false,
req = null;
const end = (r) => {
if (done) return;
done = true;
clearTimeout(timer);
resolve(r);
};
const timer = setTimeout(() => {
req?.destroy();
end({ status: 0, json: null, total: null });
}, timeoutMs);
try {
const headers = {
accept: 'application/json',
authorization: auth.basic ? 'Basic ' + Buffer.from(auth.basic.join(':')).toString('base64') : 'token ' + auth.token,
};
const data = body === undefined ? null : Buffer.from(JSON.stringify(body));
if (data) {
headers['content-type'] = 'application/json';
headers['content-length'] = String(data.length);
}
req = httpsRequest(new URL(path, url), { method, headers, ca, agent: false }, (res) => {
const chunks = [];
let size = 0;
res.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) {
req.destroy();
end({ status: 0, json: null, total: null });
} else chunks.push(c);
});
res.on('end', () => {
let json = null;
try {
json = size ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null;
} catch {}
const t = res.headers['x-total-count'];
end({ status: res.statusCode, json, total: t === undefined || !/^\d+$/.test(t) ? null : Number(t) });
});
res.on('error', () => end({ status: 0, json: null, total: null }));
});
req.on('error', () => end({ status: 0, json: null, total: null }));
req.end(data ?? undefined);
} catch {
end({ status: 0, json: null, total: null });
}
});
}
const segment = (value) => encodeURIComponent(String(value));
// C1 to C4 (section 3a), each call through the api's log. Throws the refusal code. Returns the admin
// token's own name from C3 (F7), which rotate deletes by.
export async function adminChecks(api, account) {
const v = await api.version();
if (v.status !== 200) deny('gitea-failed');
if (!versionAtLeast(v.json?.version)) deny('gitea-too-old');
const self = await api.self();
if (self.status === 401 || self.status === 403) deny('grant-admin-account');
if (self.status !== 200) deny('gitea-failed');
if (self.json?.login !== account || self.json?.is_admin !== true) deny('grant-admin-account');
const t = await api.tokenScopes();
if (t.status !== 200) deny(t.status === 401 || t.status === 403 ? 'grant-admin-account' : 'gitea-failed');
const scopes = t.json?.scopes;
if (!Array.isArray(scopes) || [...scopes].sort().join() !== ADMIN_SCOPES.join()) deny('grant-admin-scope');
const r = await api.reposCheck();
if (r.status !== 200 || !Array.isArray(r.json)) deny('gitea-failed');
if (r.json.length !== 0 || (r.total !== null && r.total !== 0)) deny('grant-admin-not-dedicated');
return typeof t.json?.name === 'string' ? t.json.name : null;
}
// One token file's text, read with Credentials' checks: no symlink, a regular file the stack's user owns,
// 0600, at most 16 KiB, then the token format. null if `missing` and there is no file.
function readSecretFile(file, { missing = false } = {}) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
} catch (e) {
if (missing && e.code === 'ENOENT') return null;
deny('credential-file');
}
try {
const s = fstatSync(fd);
if (!s.isFile() || s.uid !== process.getuid() || (s.mode & 0o777) !== 0o600 || s.size > 16384) deny('credential-file');
return readFileSync(fd, 'utf8');
} catch (e) {
if (e instanceof BusError) throw e;
deny('credential-file');
} finally {
closeSync(fd);
}
}
export function checkToken(token) {
if (typeof token !== 'string' || token.length > 16384 || !TOKEN.test(token)) deny('credential-format');
const t = token.replace(/\n$/, '');
if (t.length < 16) deny('credential-format');
return t;
}
function writeFileAtomic(dir, name, text) {
const file = join(dir, name);
const tmp = file + '.tmp';
try {
unlinkSync(tmp);
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
let fd;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, text);
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(dir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
}
// The trusted Gitea half. `ca` is a PEM the HTTPS calls trust instead of the system's, for the tests'
// fake Gitea; production passes none. `send` is replaceable only for the tests' failure rows.
export function giteaGrant({ configDir, ca = undefined, timeoutMs = CALL_MS, transport = send }) {
const grantsRoot = join(configDir, 'secrets', 'grants');
const slotDir = (business) => join(grantsRoot, business, 'gitea');
const slotDirs = (business) => [grantsRoot, join(grantsRoot, business), slotDir(business)];
// Section 3c: the slot's metadata, or null when there is no slot. A slot dir or file that isn't the
// stack user's own and private refuses credential-file, as does metadata of any other shape.
const adminMeta = (business) => {
for (const dir of slotDirs(business)) if (!privateDir(dir, { missing: true })) return null;
const text = readSecretFile(join(slotDir(business), 'admin.json'), { missing: true });
if (text === null) return null;
let meta;
try {
meta = JSON.parse(text);
} catch {
deny('credential-file');
}
if (
!meta || typeof meta !== 'object' || Array.isArray(meta) ||
Object.keys(meta).sort().join() !== [...META_KEYS].sort().join() ||
!parseGiteaUrl(meta.url).url || !giteaName(meta.account) || !day(meta.rotateBy) ||
typeof meta.setAt !== 'string' || !AT.test(meta.setAt)
)
deny('credential-file');
return { url: meta.url, account: meta.account, rotateBy: meta.rotateBy, setAt: meta.setAt };
};
const adminToken = (business) => {
for (const dir of slotDirs(business)) privateDir(dir);
return checkToken(readSecretFile(join(slotDir(business), 'admin')));
};
// The admin api over one token. Basic on the token routes (F1), the `token` header elsewhere. Every
// call goes through log(op, run, after).
const adminApi = (url, account, token, log) => {
const header = { token };
const basic = { basic: [account, token] };
const go = (op, method, path, auth, body, after) => log(op, () => transport({ url, method, path, auth, body, ca, timeoutMs }), after);
return {
version: () => go('version', 'GET', '/api/v1/version', header),
self: () => go('self', 'GET', '/api/v1/user', header),
tokenScopes: () => go('token-scopes', 'GET', '/api/v1/token', header),
reposCheck: () => go('repos-check', 'GET', '/api/v1/user/repos?limit=1&page=1', header),
userGet: (user) => go('user-get', 'GET', `/api/v1/users/${segment(user)}`, header),
userCreate: (body, after) => go('user-create', 'POST', '/api/v1/admin/users', header, body, after),
userPatch: (user, body) => go('user-patch', 'PATCH', `/api/v1/admin/users/${segment(user)}`, header, body),
tokenList: (user, page) => go('token-list', 'GET', `/api/v1/users/${segment(user)}/tokens?limit=50&page=${page}`, basic),
tokenCreate: (user, name, scopes = SEAT_SCOPES, op = 'token-create') =>
go(op, 'POST', `/api/v1/users/${segment(user)}/tokens`, basic, { name, scopes: [...scopes] }),
tokenDelete: (user, idOrName) => go('token-delete', 'DELETE', `/api/v1/users/${segment(user)}/tokens/${segment(idOrName)}`, basic),
collaboratorAdd: (owner, repo, user, permission) =>
go('collaborator-add', 'PUT', `/api/v1/repos/${segment(owner)}/${segment(repo)}/collaborators/${segment(user)}`, { token }, { permission }),
};
};
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;
}