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.
469 lines
25 KiB
JavaScript
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;
|
|
}
|