import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { BusinessError, businessFilePath, configDir, loadBusiness, validateBusinessDocument } from "../src/index.mjs"; import { THREAD_IDLE_SECONDS } from "../src/organization-config.mjs"; import { CREATIVE_DEFINITIONS } from "../src/vocabulary.mjs"; import { launchDoc, businessDoc, REPO, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs"; test('C2-04: library mappings load without roots, remain frozen, and retain mapping refusals', async t => { const { mappingEntry } = await import('../../library/tests/helpers.mjs'); const { rmSync } = await import('node:fs'); const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); const doc = businessDoc(root), file = join(root, 'businesses/acme.json'); doc.projects = { books: mappingEntry(), ...doc.projects }; doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; const loaded = validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }); const { checkMapping } = await import('../../library/src/mapping.mjs'); assert.deepEqual(loaded.projects.books, checkMapping(mappingEntry())); assert.equal(loaded.projects.books.root, undefined); assert.ok(Object.isFrozen(loaded.projects.books.universes.harbor.books)); const only = structuredClone(doc); delete only.projects.stack; assert.deepEqual(Object.keys(validateBusinessDocument(only, file, { rolesDir: REPO_ROLES }).projects), ['books']); // C2-01c: a prefix outside every book is a universe-level playground; one inside another // universe's book refuses. doc.projects.books.universes.harbor.playground = ['Elsewhere/Playground/']; assert.deepEqual(validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }).projects.books.universes.harbor.playground, ['Elsewhere/Playground/']); doc.projects.books.universes.harbor.playground = ['Second World/Playground/']; refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-playground-other-universe/); // C2-01d: a supporting prefix inside another universe's book refuses with its own code. doc.projects.books = mappingEntry(); doc.projects.books.universes.harbor.supporting = ['Second World/Notes/']; refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-supporting-other-universe/); doc.projects.books = { ...mappingEntry(), root: '/forbidden' }; refuses(() => validateBusinessDocument(doc, file, { rolesDir: REPO_ROLES }), /library-mapping-invalid/); }); test('C2-04: an otherwise valid publish grant cannot name a library repo', async t => { const { mappingEntry } = await import('../../library/tests/helpers.mjs'); const { rmSync } = await import('node:fs'); const root = tmp(); t.after(() => rmSync(root, { recursive: true, force: true })); const doc = businessDoc(root); doc.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' }; doc.roles.chief.credentials = { gitea: { ...doc.roles.coder.credentials.gitea } }; doc.roles.coder.identity = { name: 'Coder', email: 'coder@example.invalid', gitea: { url: 'https://git.example', user: 'acme-coder' } }; doc.roles.coder.publish = ['books']; refuses(() => validateBusinessDocument(doc, join(root, 'businesses/acme.json'), { rolesDir: REPO_ROLES }), /publish.*library/); }); function refuses(fn, pattern, exitCode = 2) { assert.throws(fn, (error) => { assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`); assert.equal(error.exitCode, exitCode); assert.match(error.message, pattern); return true; }); } // Validate a mutated copy of the fixture document. function check(mutate, { rolesDir = REPO_ROLES } = {}) { const root = tmp(); const doc = businessDoc(root); mutate?.(doc, root); return validateBusinessDocument(doc, join(root, "businesses", "acme.json"), { rolesDir }); } test("config directory and file path follow MOSAIC_CONFIG", () => { assert.equal(configDir({ MOSAIC_CONFIG: "/x/cfg/config.json" }), "/x/cfg"); assert.match(configDir({}), /\.config\/mosaic-dev$/); assert.equal(businessFilePath("acme", "/x/cfg"), "/x/cfg/businesses/acme.json"); refuses(() => businessFilePath("../acme", "/x"), /business id/); }); test("the fixture business validates and comes back frozen", () => { const business = check(); assert.equal(business.id, "acme"); assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer", "chief"]); assert.equal(business.roles.pm.holder, "sage"); assert.equal(business.roles.reviewer.holder, null); assert.equal(business.roles.coder.vars["limits.network"], "none"); assert.equal(business.definitions.pm.roleVersion, 2); assert.deepEqual(business.launch, { ...launchDoc(), threadIdleSeconds: THREAD_IDLE_SECONDS }); assert.ok(Object.isFrozen(business.roles.pm.credentials.gitea)); assert.throws(() => { business.roles.pm.holder = "x"; }, TypeError); }); test("two instances may share a definition", () => { const business = check((doc) => { doc.roles.coder2 = { ...structuredClone(doc.roles.coder), tracker: { bot: "bot-acme-coder2", botId: 7 } }; }); assert.equal(business.roles.coder2.definition, "coder"); }); test("top-level refusals", () => { refuses(() => check((d) => { d.businessVersion = 2; }), /businessVersion/); refuses(() => check((d) => { d.owner = "x"; }), /unsupported business file key: "owner"/); refuses(() => check((d) => { d.id = "other"; }), /must match its filename/); refuses(() => check((d) => { d.id = "Acme"; }), /business id/); for (const key of ["human", "arbiters", "projects", "roles"]) { refuses(() => check((d) => { delete d[key]; }), new RegExp(`requires "${key}"`)); } refuses(() => check((d) => { d.vars.harness = "pi"; }), /harness can't be set at the business layer/); refuses(() => check((d) => { d.vars.secret = "x"; }), /unknown variable "secret"/); }); test("arbiters and projects", () => { refuses(() => check((d) => { d.arbiters.technical = "ghost"; }), /arbiters\.technical names ghost/); refuses(() => check((d) => { delete d.arbiters.delivery; }), /arbiters\.delivery/); refuses(() => check((d) => { d.arbiters.final = "pm"; }), /unsupported .* arbiters key/); refuses(() => check((d) => { d.projects = {}; }), /at least one project/); refuses(() => check((d) => { d.projects.stack.root = "relative/path"; }), /normalized absolute path/); refuses(() => check((d) => { d.projects.stack.root += "/../x"; }), /normalized absolute path/); refuses(() => check((d) => { d.projects.stack.branch = "main"; }), /unsupported .* key: "branch"/); }); test("role instances", () => { refuses(() => check((d) => { d.roles = {}; }), /at least one role instance/); refuses(() => check((d) => { d.roles.pm.definition = "ghost"; }), /not found/, 4); refuses(() => check((d) => { d.roles.pm.definition = "researcher"; }), /version 1 and can't back/); refuses(() => check((d) => { d.roles.pm.token = "x"; }), /unsupported .*roles\.pm key: "token"/); refuses(() => check((d) => { d.roles.pm.holder = "Sage!"; }), /holder/); refuses(() => check((d) => { d.roles.pm.vars["tracker.baseUrl"] = "http://x"; }), /can't be set at the agent layer/); refuses(() => check((d) => { d.roles.pm.vars["limits.authority"] = ["deploy.prod"]; }), /unknown action/); }); test("Vikunja bots", () => { refuses(() => check((d) => { delete d.roles.pm.tracker; }), /needs "tracker"/); refuses(() => check((d) => { d.roles.pm.tracker.botId = 0; }), /botId must be a positive integer/); refuses(() => check((d) => { d.roles.pm.tracker.bot = "pm-bot"; }), /starting with "bot-"/); refuses(() => check((d) => { d.roles.cto.tracker.bot = d.roles.pm.tracker.bot; }), /used by another instance/); refuses(() => check((d) => { d.roles.cto.tracker.botId = d.roles.pm.tracker.botId; }), /botId 3 is used by another instance/); refuses(() => check((d) => { d.tracker.sync.bot = d.roles.pm.tracker.bot; }), /sync must use its own bot/); refuses(() => check((d) => { d.tracker.sync.botId = d.roles.cto.tracker.botId; }), /sync must use its own bot/); refuses(() => check((d) => { d.tracker.sync.botId = 0; }), /sync\.botId/); refuses(() => check((d) => { d.tracker.labels = { "needs-jason": 0 }; }), /labels\.needs-jason/); refuses(() => check((d) => { d.tracker.webhook = "x"; }), /unsupported .* tracker key/); assert.deepEqual(check((d) => { d.tracker.labels = { "needs-jason": 12 }; }).tracker.labels, { "needs-jason": 12 }); }); test("a role without Vikunja takes no tracker block", () => { const root = tmp(); const rolesDir = rolesCopy(root); const pm = JSON.parse(readFileSync(join(rolesDir, "pm.json"), "utf8")); pm.credentials = pm.credentials.filter((c) => c.service === "gitea"); writeJson(join(rolesDir, "pm.json"), pm); refuses(() => check(undefined, { rolesDir }), /has "tracker" but pm uses no vikunja credential/); refuses(() => check((d) => { delete d.roles.pm.tracker; }, { rolesDir }), /exactly the services its role definition needs \(gitea; got gitea, vikunja\)/); const business = check((d) => { delete d.roles.pm.tracker; delete d.roles.pm.credentials.vikunja; }, { rolesDir }); assert.equal(business.roles.pm.tracker, null); }); test("credential references match the definition's services", () => { refuses(() => check((d) => { delete d.roles.cto.credentials.gitea; }), /exactly the services .* \(gitea, vikunja; got vikunja\)/); refuses(() => check((d) => { d.roles.cto.credentials.github = { env: "X", rotateBy: "2099-01-01" }; }), /exactly the services/); refuses(() => check((d) => { d.roles.cto.credentials.gitea = { env: "X" }; }), /needs "rotateBy"/); refuses(() => check((d) => { d.roles.cto.credentials.vikunja.token = "tk_x"; }), /unsupported .* key: "token"/); refuses(() => check((d) => { d.tracker.sync.credentials = {}; }), /exactly the services .* \(vikunja; got none\)/); }); test("launch", () => { assert.equal(check((d) => { delete d.launch; }).launch, null); refuses(() => check((d) => { d.launch.by = "ghost"; }), /launch\.by names ghost/); refuses(() => check((d) => { d.launch.by = "cto"; d.launch.instances = ["coder"]; }), /launch\.by names cto, whose role cto doesn't hold role\.launch within-role/); refuses(() => check((d) => { d.launch.instances = []; }), /must not be empty/); refuses(() => check((d) => { d.launch.instances = ["coder", "coder"]; }), /duplicate/); refuses(() => check((d) => { d.launch.instances = ["ghost"]; }), /instances names ghost/); refuses(() => check((d) => { d.launch.instances = ["chief"]; }), /can't include the launcher itself/); refuses(() => check((d) => { d.launch.models = {}; }), /at least one model/); refuses(() => check((d) => { d.launch.models = { glm: {} }; }), /provider/); assert.equal(check((d)=>{d.launch.limits.total=50;}).launch.limits.total,50); refuses(() => check((d) => { d.launch.limits.total = -1; }), /whole number/); refuses(() => check((d) => { d.launch.limits.total = 1.5; }), /whole number/); refuses(() => check((d) => { d.launch.cap = 1; }), /unsupported .* launch key/); assert.deepEqual(check((d) => { d.launch.limits = {total:0}; }).launch.limits, {total:0}); }); // REQ-THREAD-2 (row 121, test 10): an absent threadIdleSeconds is the one // exported default, a set value is used, and anything but a positive integer // refuses. No upper bound (Q150). test("REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, a positive integer is used, anything else refuses", () => { assert.equal(THREAD_IDLE_SECONDS, 900); assert.equal(check().launch.threadIdleSeconds, 900); assert.equal(check((d) => { d.launch.threadIdleSeconds = 60; }).launch.threadIdleSeconds, 60); for (const bad of [0, -1, 1.5, "900", null]) { refuses(() => check((d) => { d.launch.threadIdleSeconds = bad; }), /launch\.threadIdleSeconds must be a positive integer/); } assert.equal(check((d) => { d.launch.threadIdleSeconds = Number.MAX_SAFE_INTEGER; }).launch.threadIdleSeconds, Number.MAX_SAFE_INTEGER); }); test("loadBusiness: file checks", () => { const root = tmp(); const dir = join(root, "config"); refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file acme\.json not found/, 4); const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root)); assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).file, file); chmodSync(file, 0o620); refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 620\)/); chmodSync(file, 0o602); refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /writable by group or other/); chmodSync(file, 0o644); assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).id, "acme"); writeJson(join(dir, "businesses", "real.json"), businessDoc(root, "linked")); symlinkSync("real.json", join(dir, "businesses", "linked.json")); refuses(() => loadBusiness("linked", { dir, rolesDir: REPO_ROLES }), /non-symbolic-link/, 4); writeFileSync(join(dir, "businesses", "broken.json"), "{ not json"); refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /not valid JSON/); // The owner and mode checks run on the opened file before the read, so // a group-writable file refuses for its mode even when it won't parse. chmodSync(join(dir, "businesses", "broken.json"), 0o660); refuses(() => loadBusiness("broken", { dir, rolesDir: REPO_ROLES }), /writable by group or other \(mode 660\)/); assert.throws(() => loadBusiness("acme", { dir }), /needs rolesDir/); }); // A directory or a FIFO under the business name refuses without a read. // Opening a FIFO without O_NONBLOCK would block the whole process, which // node:test can't time out, so that case runs in a child with a timeout. test("loadBusiness: not a regular file", () => { const dir = join(tmp(), "config"); mkdirSync(join(dir, "businesses", "folder.json"), { recursive: true }); refuses(() => loadBusiness("folder", { dir, rolesDir: REPO_ROLES }), /must be a regular, non-symbolic-link file/, 4); execFileSync("mkfifo", [join(dir, "businesses", "pipe.json")]); const index = pathToFileURL(join(REPO, "packages", "organization", "src", "index.mjs")).href; const child = spawnSync(process.execPath, ["--input-type=module", "-e", ` import { loadBusiness } from ${JSON.stringify(index)}; try { loadBusiness("pipe", { dir: ${JSON.stringify(dir)}, rolesDir: ${JSON.stringify(REPO_ROLES)} }); } catch (error) { console.log(error.exitCode, error.message); } `], { encoding: "utf8", timeout: 5000 }); assert.equal(child.signal, null, "opening the FIFO waited for a writer"); assert.match(child.stdout, /^4 .*must be a regular, non-symbolic-link file/); }); test("loading writes nothing", () => { const root = tmp(); const dir = join(root, "config"); const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root)); const listing = (d) => readdirSync(d, { recursive: true }).sort().join("\n"); const before = { tree: listing(root), mtime: lstatSync(file).mtimeMs, text: readFileSync(file, "utf8") }; loadBusiness("acme", { dir, rolesDir: REPO_ROLES }); assert.equal(listing(root), before.tree); assert.equal(lstatSync(file).mtimeMs, before.mtime); assert.equal(readFileSync(file, "utf8"), before.text); }); test("names that are Object.prototype properties don't count as declared", () => { refuses(() => check((d) => { d.arbiters.delivery = "constructor"; }), /arbiters\.delivery names constructor/); refuses(() => check((d) => { d.launch.by = "constructor"; }), /launch\.by names constructor/); refuses(() => check((d) => { d.launch.instances = ["constructor"]; }), /instances names constructor/); refuses(() => check((d) => { d.roles.pm.vars.model = "constructor"; }), /variable model selects an unknown model/); refuses(() => check((d) => { d.roles.pm.definition = "constructor"; }), /not found/, 4); const labels = check((d) => { d.tracker.labels = JSON.parse('{"__proto__": 5}'); }).tracker.labels; assert.equal(Object.getPrototypeOf(labels), Object.prototype); assert.equal(Object.getOwnPropertyDescriptor(labels, "__proto__").value, 5); }); test("the shipped example refuses as written and validates once filled in", () => { const example = readFileSync(join(REPO, "packages", "organization", "examples", "mosaic-stack.example.json"), "utf8"); const root = tmp(); const file = join(root, "businesses", "mosaic-stack.json"); refuses(() => validateBusinessDocument(JSON.parse(example), file, { rolesDir: REPO_ROLES }), /botId must be a positive integer/); let id = 10; const filled = JSON.parse(example .replaceAll('"botId": 0', () => `"botId": ${id++}`) .replaceAll("YYYY-MM-DD", "2099-01-01") .replaceAll("/home/you", root)); const business = validateBusinessDocument(filled, file, { rolesDir: REPO_ROLES }); assert.deepEqual(Object.keys(business.roles), ["pm", "cto", "coder", "reviewer", "chief", "assistant"]); // Row 133 (#1627): the arbiters declare no messaging lists, so the limited assistant beside them passes. assert.deepEqual(business.arbiters, { delivery: "pm", technical: "cto" }); assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token")); assert.equal(id, 15); }); // Row 285 (C2-08b, #1790, mos-1330): a creative definition declares gitea, so its instance names // the reference. The store form needs no token file, and the bus reads a missing one as no token. const CREATIVE_GITEA = Object.freeze({ gitea: Object.freeze({ store: true, rotateBy: "2099-01-01" }) }); // Row 213 (C2-08, #1714), deviation D1: the persona vars sit on a creative instance only, and // both or neither. The var checks themselves are vars.mjs's. test("persona vars: agent layer, creative definition only, both or neither", () => { const sha = "a".repeat(64); const persona = { "persona.name": "Bean", "persona.sha256": sha }; const business = check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona } }; }); assert.deepEqual({ ...business.roles.bean.vars }, persona); assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition: "ideas", credentials: CREATIVE_GITEA }; })); refuses(() => check((d) => { d.vars["persona.name"] = "Bean"; }), /persona\.name can't be set at the business layer/); refuses(() => check((d) => { d.roles.pm.vars = { ...d.roles.pm.vars, ...persona }; }), /roles\.pm\.vars: persona vars need a creative definition \(pm isn't one\)/); refuses(() => check((d) => { d.roles.chief.vars = { ...persona }; }), /chief-of-staff isn't one/); for (const key of Object.keys(persona)) { refuses(() => check((d) => { d.roles.bean = { definition: "research", credentials: CREATIVE_GITEA, vars: { [key]: persona[key] } }; }), /roles\.bean\.vars: persona\.name and persona\.sha256 are set together or not at all/); } for (const [key, value] of [["persona.name", "x".repeat(41)], ["persona.name", "Be\nan"], ["persona.name", "Be\u0007an"], ["persona.name", "Be\u2028an"], ["persona.name", "Be\u2029an"], ["persona.name", 7], ["persona.sha256", "A".repeat(64)], ["persona.sha256", sha.slice(1)], ["persona.sha256", `${sha}0`]]) { refuses(() => check((d) => { d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, vars: { ...persona, [key]: value } }; }), new RegExp(`variable ${key.replace(".", "\\.")}`), 2); } }); // Row 285 (C2-08b, #1790, mos-1330): the chief and each creative definition declare gitea, so an // instance without the reference refuses. Amendment 1 (mos-1580, sage-4060): the chief // requires its reference only in a library business; creative definitions always require it. test("row 285: a chief or creative instance without credentials.gitea refuses at load", async () => { const { mappingEntry } = await import("../../library/tests/helpers.mjs"); refuses(() => check((d) => { d.projects.books = mappingEntry(); delete d.roles.chief.credentials; }), /chief-library-gitea-required: .*roles\.chief\.credentials/); for (const definition of CREATIVE_DEFINITIONS) { refuses(() => check((d) => { d.roles.bean = { definition }; }), /roles\.bean\.credentials must reference exactly the services its role definition needs \(gitea; got none\)/); assert.doesNotThrow(() => check((d) => { d.roles.bean = { definition, credentials: CREATIVE_GITEA }; }), definition); } }); // Row 285 (C2-08b, brief): a creative instance may carry identity and publish, under the rules at // organization-config.mjs checkRoles (identity and publish), which the grant path reads. The grant itself still refuses it (#giteaRole). test("row 285: a creative instance with identity and publish loads", () => { const identity = { name: "Bean", email: "bean@example.test", gitea: { url: "https://git.example", user: "acme-bean" } }; const business = check((d) => { d.projects.stack.remote = "https://git.example/acme/stack"; d.roles.bean = { definition: "companion", credentials: CREATIVE_GITEA, identity, publish: ["stack"] }; }); assert.deepEqual({ ...business.roles.bean.identity, gitea: { ...business.roles.bean.identity.gitea } }, identity); assert.deepEqual([...business.roles.bean.publish], ["stack"]); }); // Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list. test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => { const plain = check(); assert.equal(Object.hasOwn(plain.launch, "keep"), false); assert.equal(Object.hasOwn(plain.launch, "noHire"), false); assert.equal(Object.hasOwn(plain, "retired"), false); const business = check((d) => { d.launch.keep = ["coder", "chief"]; d.launch.noHire = ["reviewer", "no-such-role"]; d.retired = ["old-coder"]; }); assert.deepEqual(business.launch.keep, ["coder", "chief"]); // A stale noHire entry is harmless: only the id pattern is checked. assert.deepEqual(business.launch.noHire, ["reviewer", "no-such-role"]); assert.deepEqual(business.retired, ["old-coder"]); assert.ok(Object.isFrozen(business.launch.keep) && Object.isFrozen(business.retired)); assert.deepEqual(check((d) => { d.launch.keep = []; d.launch.noHire = []; d.retired = []; }).launch.keep, []); }); test("row 194 each bad keep, noHire or retired entry refuses", () => { refuses(() => check((d) => { d.launch.keep = ["ghost"]; }), /launch\.keep names ghost, which the business file doesn't declare/); refuses(() => check((d) => { d.launch.keep = ["coder", "coder"]; }), /duplicate entry in .*launch\.keep/); refuses(() => check((d) => { d.launch.keep = "coder"; }), /launch\.keep must be an array/); refuses(() => check((d) => { d.roles.assistant = { definition: "assistant" }; d.launch.keep = ["assistant"]; }), /launch\.keep names assistant, whose definition assistant can't be kept/); refuses(() => check((d) => { d.launch.noHire = ["Bad Name"]; }), /launch\.noHire entry/); refuses(() => check((d) => { d.launch.noHire = ["coder", "coder"]; }), /duplicate entry in .*launch\.noHire/); refuses(() => check((d) => { d.retired = ["coder"]; }), /retired names coder, which roles still declares/); refuses(() => check((d) => { d.retired = ["x", "x"]; }), /duplicate entry in .*retired/); refuses(() => check((d) => { d.retired = ["../x"]; }), /retired entry/); refuses(() => check((d) => { d.retired = {}; }), /retired must be an array/); }); // C2-21 B1-B4: catch a deleted/overbroad library publish fence while retaining identity/credential rules. test('C2-21: only creative definitions and the chief with gitea may publish a library project', async (t) => { const { mappingEntry } = await import('../../library/tests/helpers.mjs'); for (const definition of [...CREATIVE_DEFINITIONS, 'chief-of-staff']) { const mutate = (d) => { d.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' }; d.roles.chief.credentials = CREATIVE_GITEA; const agent = definition === 'chief-of-staff' ? 'chief' : 'bean'; d.roles[agent] = { ...(d.roles[agent] ?? {}), definition, credentials: CREATIVE_GITEA, identity: { name: 'Library reader', email: 'reader@example.test', gitea: { url: 'https://git.example', user: 'acme-reader' } }, publish: ['books'] }; }; const business = check(mutate); assert.deepEqual(business.roles[definition === 'chief-of-staff' ? 'chief' : 'bean'].publish, ['books']); refuses(() => check((d) => { mutate(d); delete d.roles[definition === 'chief-of-staff' ? 'chief' : 'bean'].identity; }), /publish needs identity and a gitea definition/); } for (const agent of ['coder', 'reviewer', 'pm', 'cto']) { refuses(() => check((d) => { d.projects.books = { ...mappingEntry(), remote: 'https://git.example/writer/books' }; d.roles.chief.credentials = CREATIVE_GITEA; d.roles[agent].identity = { name: 'Other', email: 'other@example.test', gitea: { url: 'https://git.example', user: 'acme-other' } }; d.roles[agent].publish = ['books']; }), /publish.*library.*creative.*chief/); } });