Review P1: the CLI wrote definitions with a no-op secret check. setDefinition now refuses definition-secret-check-required when a write that adds or changes an entry has no assertClean. New bus verbs definition.import|invent|edit|remove run the writes for the business's human at the cli with the credential store's assertClean; the tester, agents and readers refuse. The CLI's definition writes call the bus and map its codes to messages that never quote the definition.
97 lines
5.9 KiB
JavaScript
97 lines
5.9 KiB
JavaScript
// #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');
|
|
}
|