// #1873 T1: `mosaic org definitions`, the human verbs on an organization's own role definitions and // the template hash and diff reads. Reads run here, from the file. A write runs only from a human // shell (refuseInsideAgent) and goes through the organization's bus as its human: the bus holds the // credential store, whose held-secret check every definition write needs (R1), and no other process // holds the tokens it looks for. Each write prints its receipt. import { readFile } from 'node:fs/promises'; import { configDir } from '../../organization/src/organization-config.mjs'; import { organizationWriter } from '../../organization/src/organization.mjs'; import { definitionVerbs } from '../../organization/src/definition-verbs.mjs'; import { checkDefinition } from '../../organization/src/definitions.mjs'; import { rolesDir } from './config.mjs'; import { CliError } from './errors.mjs'; import { busExit, refuseInsideAgent } from './transport.mjs'; export const DEFINITIONS_USAGE = 'mosaic org definitions templates | definitions ID list | definitions ID show NAME | definitions ID diff NAME | definitions ID import TEMPLATE [--as NAME] | definitions ID invent NAME --from FILE | definitions ID edit NAME --from FILE --if-rev N | definitions ID remove NAME --if-rev N'; const WRITES = ['import', 'invent', 'edit', 'remove']; const VALUES = ['--as', '--from', '--if-rev']; function parse(args) { const pos = []; const values = {}; for (let i = 0; i < args.length; i++) { const a = args[i]; if (VALUES.includes(a)) { if (values[a] !== undefined || !args[i + 1] || args[i + 1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values }; } async function definitionFile(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { // Never echo the file's text: it may hold what the composition check refuses. throw new CliError(`cannot read definition ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.code ?? 'unreadable'}`, 2); } } // The bus answers a refusal with its code only. These say what the code means for this request; // none quotes the definition's text. function refusal(code, verb, name, rev, read) { const why = { 'definition-in-use': () => `definition ${name} is named by ${read().usedBy.join(', ')}; a change to a hired agent's role is a change decision`, 'definition-rev-conflict': () => `definition ${name} is not at rev ${rev}`, 'definition-exists': () => `definitions already declares ${name}`, 'definition-missing': () => `definitions declares no ${name}`, 'revision-conflict': () => 'the organization file changed while this ran; run it again', 'credential-leak': () => `the ${verb} request holds a credential the organization's bus holds; nothing was written`, 'invalid-request': () => `the bus refused the ${verb}: the definition fails the composition check or holds a held credential`, }[code]; let detail = null; try { detail = why?.() ?? null; } catch { detail = null; } const error = new CliError(detail ? `${code}: ${detail}` : code, busExit(code)); error.code = code; return error; } // deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs // passes its callFor). Without one, a write refuses: it never runs without the bus's secret check. export async function definitionsCommand(args, io, deps = {}) { const { pos, values } = parse(args); const allowed = (...keys) => { for (const k of Object.keys(values)) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); }; const need = (key) => { if (!values[key]) throw new CliError(`${key} is required`, 4); return values[key]; }; const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); }; const [id, verb, name] = pos; if (WRITES.includes(verb)) refuseInsideAgent(io.env); const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) }); const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) }); let result; if (id === 'templates' && pos.length === 1) { allowed(); result = verbs.templates(); } else if (verb === 'list' && pos.length === 2) { allowed(); result = verbs.list(id); } else if (['show', 'diff'].includes(verb) && pos.length === 3) { allowed(); result = verbs[verb](id, name); } else if (WRITES.includes(verb) && pos.length === 3) { let request, rev; if (verb === 'import') { allowed('--as'); request = { template: name, as: values['--as'] ?? name }; } else if (verb === 'invent') { allowed('--from'); request = { name, definition: await definitionFile(need('--from')) }; } else if (verb === 'edit') { allowed('--from', '--if-rev'); rev = ifRev(); request = { rev, name, definition: await definitionFile(need('--from')) }; } else { allowed('--if-rev'); rev = ifRev(); request = { rev, name }; } // The file's own checks first, so a bad key is named here; the bus repeats them with its secret check. if (request.definition) checkDefinition(name, { ...request.definition, rev: rev ?? 1 }); if (typeof deps.callFor !== 'function') throw new CliError('a definition write goes through the organization bus; run it as `mosaic org definitions`', 3); const call = deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } }); const { revision } = writer.read(id); try { result = await call(`definition.${verb}`, { revision, ...request }); } catch (e) { if (typeof e?.code !== 'string' || e.code === '') throw e; throw refusal(e.code, verb, request.as ?? name, rev, () => verbs.show(id, request.as ?? name)); } } else throw new CliError(DEFINITIONS_USAGE, 4); io.stdout.write(JSON.stringify(result, null, 2) + '\n'); }