Files
jason.woltje 079ced93f3 feat(packs): pack, board and workflow verbs, routes and CLI words (#1873 SP4)
Design section 8. The bus answers packs, pack.preview, pack.apply,
board.set and workflow.set to the organization's human, and their
.console forms to the owner's Console cap. The broker runs the section 3
merge, the plan digest (pack-plan-stale on a mismatch) and the held
credential check. The organization writer keeps packs, board and
workflows out of the generic write and refuses definition writes to
every actor name an agent carries, project agents included.

mosaic org pack list|preview|apply, board set and workflow set|remove
go over the organization's bus as its human and refuse inside an agent
shell before any file is read. The Console serves GET /api/orgs/<id>/packs,
packs/preview, packs/apply, board and workflows/<name> (POST and
DELETE) to the root owner's session only, writes under --send only.
2026-11-12 16:51:46 -06:00

255 lines
12 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import * as policy from '../src/org-rights.mjs';
import { CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS } from '../../bus/src/broker.mjs';
// Accounts design draft 3, section 3 and M1 (#1873). Expected assignments are
// written out: adding a real bus verb or widening a bundle must change this pin.
const RIGHTS = {
'org.view': { grantable: true },
'thread.read': { grantable: true },
'thread.write': { grantable: true },
'board.read': { grantable: true },
'task.write': { grantable: true },
decide: { grantable: true },
'org.hire': { grantable: true },
'request.write': { grantable: false },
'members.manage': { grantable: false },
'org.setup': { grantable: false },
'org.lifecycle': { grantable: false },
};
const BUNDLES = {
owner: ['board.read', 'decide', 'members.manage', 'org.hire', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
member: ['board.read', 'org.view', 'thread.read'],
collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'],
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
};
const VERBS = {
'autonomy.get.console': null,
'autonomy.set.console': null,
'autonomy.review.console': null,
'decision.confirm.console': null,
'decision.reverse.console': null,
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
'thread.messages': 'thread.read',
plugins: 'thread.read',
'thread.open': 'thread.write',
'thread.create': 'thread.write',
'thread.send': 'thread.write',
'thread.share': 'thread.write',
'thread.unshare': 'thread.write',
'thread.shares': 'thread.write',
inbox: 'board.read',
agents: 'board.read',
'thread.claims': 'board.read',
tasks: 'board.read',
decisions: 'board.read',
usage: 'board.read',
limits: 'board.read',
'request.get.console': 'board.read',
'request.projects.console': 'board.read',
'request.artifact.console': 'board.read',
'request.workspace.console': 'board.read',
trail: null,
credentials: null,
replies: null,
messages: null,
state: null,
'tasks.create': 'task.write',
'tasks.edit': 'task.write',
'tasks.archive': 'task.write',
'tasks.unarchive': 'task.write',
'tasks.delete': null,
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'hire.resolve.console': 'org.hire',
'hire.preview.console': 'org.hire',
'hire.start.console': 'org.hire',
'hire.templates.console': 'org.hire',
'definition.import.console': 'org.hire',
'definition.edit.console': 'org.hire',
'definition.invent.console': 'org.hire',
'definition.remove.console': 'org.hire',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
'request.mode.console': 'request.write',
'request.draft.console': 'request.write',
'prd.revoke.console': 'request.write',
'plugins.set': 'org.setup',
'system-types.console': 'org.setup',
'project.remote.get.console': 'org.setup',
'project.remote.set.console': 'org.setup',
// #1873 SP4: starter packs, the board and workflows. The list is a board read; every write is
// the owner's alone (null): a pack copies roles, and nothing else writes board or workflows.
'packs.console': 'board.read',
'pack.preview.console': null,
'pack.apply.console': null,
'board.set.console': null,
'workflow.set.console': null,
};
test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => {
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
});
test('R1 org.hire belongs to owner and no non-owner template', () => {
assert.ok(policy.ROLE_RIGHTS.owner.includes('org.hire'));
for (const role of ['member', 'collaborator', 'approver']) {
assert.ok(!policy.ROLE_RIGHTS[role].includes('org.hire'), role);
}
});
test('R1 an owner can grant org.hire to a member without widening the template', () => {
const rights = policy.normalizeRights(policy.ROLE_RIGHTS.member, { add: ['org.hire'] });
assert.deepEqual(rights, ['board.read', 'org.hire', 'org.view', 'thread.read']);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, rights), true);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, ['org.hire']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, ['org.hire', 'future.right']), false);
assert.throws(() => policy.normalizeRights(rights, { add: ['future.right'] }), { code: 'unknown-right' });
assert.deepEqual(policy.ROLE_RIGHTS.member, ['board.read', 'org.view', 'thread.read']);
});
test('R1 hire resolution requires org.hire while generic decision resolution keeps decide', () => {
assert.equal(policy.VERB_RIGHTS['hire.resolve.console'], 'org.hire');
assert.equal(policy.VERB_RIGHTS['decision.resolve.console'], 'decide');
});
for (const [role, rights] of Object.entries(BUNDLES)) {
test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => {
assert.deepEqual(policy.ROLE_RIGHTS[role], rights);
});
}
test('M1 owner holds every right; only owner holds any reserved right', () => {
assert.deepEqual(policy.ROLE_RIGHTS.owner, Object.keys(policy.RIGHTS).sort());
for (const role of ['member', 'collaborator', 'approver']) {
for (const right of policy.ROLE_RIGHTS[role]) assert.equal(policy.RIGHTS[right].grantable, true, `${role}: ${right}`);
assert.ok(!policy.ROLE_RIGHTS[role].includes('request.write'));
}
assert.ok(!policy.ROLE_RIGHTS.member.includes('decide'));
assert.ok(!policy.ROLE_RIGHTS.member.includes('task.write'));
});
test('M1 canGrant allows only known grantable rights held by the granter', () => {
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, policy.ROLE_RIGHTS.approver), true);
assert.equal(policy.canGrant(['org.view', 'decide'], ['decide']), true);
assert.equal(policy.canGrant(['org.view'], ['thread.read']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, policy.ROLE_RIGHTS.collaborator), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, []), true);
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, [right]), false, right);
}
assert.equal(policy.canGrant(['org.view', 'future.right'], ['org.view']), false);
});
test('M1 canGrant refuses malformed lists and values, including sparse arrays', () => {
for (const value of [undefined, null, 'owner', {}, 1, [null], [1], [new String('org.view')], Array(1)]) {
assert.equal(policy.canGrant(value, ['org.view']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, value), false);
}
});
test('M1 normalizeRights sorts, deduplicates, adds org.view and applies explicit additions and removals', () => {
const rights = Object.freeze(['thread.read', 'board.read', 'thread.read']);
assert.deepEqual(policy.normalizeRights(rights), ['board.read', 'org.view', 'thread.read']);
assert.deepEqual(policy.normalizeRights([]), ['org.view']);
const add = Object.freeze(['task.write', 'decide', 'task.write']);
const remove = Object.freeze(['thread.read']);
assert.deepEqual(policy.normalizeRights(rights, { add, remove }), ['board.read', 'decide', 'org.view', 'task.write']);
assert.deepEqual(rights, ['thread.read', 'board.read', 'thread.read']);
assert.notEqual(policy.normalizeRights(rights), rights);
});
test('M1 normalization refuses any attempt to remove org.view, even when re-added', () => {
for (const rights of [[], ['org.view']]) {
assert.throws(() => policy.normalizeRights(rights, { add: ['org.view'], remove: ['org.view'] }), { code: 'right-not-removable' });
}
});
test('M1 normalization refuses reserved, owner and unknown rights in every input before returning a result', () => {
for (const right of ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle', 'future.right', '__proto__', 'constructor', 'toString']) {
const code = ['owner', 'request.write', 'members.manage', 'org.setup', 'org.lifecycle'].includes(right) ? 'right-not-grantable' : 'unknown-right';
for (const [rights, options] of [[[right], {}], [[], { add: [right] }], [[], { remove: [right] }]]) {
assert.throws(() => policy.normalizeRights(rights, options), { code }, right);
}
}
});
test('M1 normalization refuses malformed lists, options and non-string rights', () => {
for (const value of [undefined, null, {}, 'member', 0, [null], [42], [new String('org.view')], Array(1)]) {
assert.throws(() => policy.normalizeRights(value));
assert.throws(() => policy.normalizeRights([], { add: value }));
assert.throws(() => policy.normalizeRights([], { remove: value }));
}
for (const options of [null, [], 'member', 1, { typo: [] }]) {
assert.throws(() => policy.normalizeRights([], options), { code: 'invalid-rights' });
}
});
test('M1 VERB_RIGHTS pins every assignment from section 3, with explicit null for unmapped owner-only operations', () => {
assert.deepEqual({ ...policy.VERB_RIGHTS }, VERBS);
for (const right of Object.values(policy.VERB_RIGHTS)) assert.ok(right === null || Object.hasOwn(policy.RIGHTS, right));
});
test('M1 every actual console, user, board and person-task verb has a reviewed assignment', () => {
for (const verbs of [CONSOLE_VERBS, USER_VERBS, BOARD_VERBS, PERSON_TASK_VERBS]) {
for (const verb of verbs) {
assert.ok(Object.hasOwn(VERBS, verb), `${verb} needs a reviewed assignment`);
assert.ok(Object.hasOwn(policy.VERB_RIGHTS, verb), `${verb} missing from VERB_RIGHTS`);
assert.equal(policy.VERB_RIGHTS[verb], VERBS[verb], verb);
}
}
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
'hire.resolve.console', // R1 reserves the policy mapping; A4 implements the verb.
// T1: the definition verbs. The CLI calls them today; the Console route comes later.
'definition.import.console', 'definition.edit.console', 'definition.invent.console', 'definition.remove.console',
])].sort());
});
test('M1 sensitive board reads, task delete, intake and PRD revoke remain owner-only', () => {
for (const verb of ['trail', 'credentials', 'replies', 'messages', 'state', 'tasks.delete']) {
assert.equal(policy.VERB_RIGHTS[verb], null, verb);
}
for (const verb of ['request.answer.console', 'request.attach.console', 'request.cancel.console', 'request.mode.console', 'request.draft.console', 'prd.revoke.console']) {
assert.equal(policy.VERB_RIGHTS[verb], 'request.write', verb);
assert.equal(policy.RIGHTS[policy.VERB_RIGHTS[verb]].grantable, false);
}
});
test('M1 unknown verbs, rights and role property names never inherit an assignment', () => {
for (const name of ['future.verb', 'tasks.foo', 'task.create', 'owner', '__proto__', 'constructor', 'toString', '', undefined, null]) {
assert.equal(policy.VERB_RIGHTS[name], undefined);
assert.equal(policy.RIGHTS[name], undefined);
if (name !== 'owner') assert.equal(policy.ROLE_RIGHTS[name], undefined);
assert.throws(() => policy.rightLevel(name), { code: 'unknown-right' });
}
});
test('M1 only thread.write needs collaborator; every other known right needs viewer', () => {
for (const right of Object.keys(RIGHTS)) {
assert.equal(policy.rightLevel(right), right === 'thread.write' ? 'collaborator' : 'viewer', right);
}
for (const value of [{}, [], 1, new String('org.view')]) assert.throws(() => policy.rightLevel(value), { code: 'unknown-right' });
});
test('M1 policy records, grantable flags, bundles and normalized rights are frozen', () => {
for (const map of [policy.RIGHTS, policy.ROLE_RIGHTS, policy.VERB_RIGHTS]) {
assert.equal(Object.getPrototypeOf(map), null);
assert.ok(Object.isFrozen(map));
assert.throws(() => { map.extra = 'decide'; }, TypeError);
}
for (const descriptor of Object.values(policy.RIGHTS)) {
assert.ok(Object.isFrozen(descriptor));
assert.throws(() => { descriptor.grantable = !descriptor.grantable; }, TypeError);
}
for (const bundle of Object.values(policy.ROLE_RIGHTS)) {
assert.ok(Object.isFrozen(bundle));
assert.throws(() => bundle.push('decide'), TypeError);
}
assert.ok(Object.isFrozen(policy.normalizeRights(['thread.read'])));
});