Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b2199390 | ||
|
|
4c495647cd | ||
|
|
f5cc712a59 |
No files matched your search
@@ -185,6 +185,8 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
|
||||
// Row 275 (row 193 design 4.3): the owner's board reads, which a reader session may call. A user,
|
||||
// collaborator or viewer, is refused every one at the USER_VERBS gate.
|
||||
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
|
||||
// The person's task-store writes, distinct from the agent's singular task.* actions.
|
||||
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -6915,6 +6917,8 @@ export class Broker {
|
||||
#dispatch(s, verb, a, spec = null, binding = null) {
|
||||
const b = this.#business(s.business),
|
||||
by = s.human || s.run;
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as bus from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
|
||||
function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'pt-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const broker = new bus.Broker({ store, businesses: {
|
||||
acme: { id: 'acme', human: 'jason', roles: {
|
||||
pm: { authority: { withinRole: [], crossRole: [] } },
|
||||
cto: { authority: { withinRole: [], crossRole: [] } },
|
||||
}, arbiters: { technical: 'cto', delivery: 'pm' } },
|
||||
} });
|
||||
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
return { broker, human };
|
||||
}
|
||||
|
||||
test('person task vocabulary pins exactly the five frozen store verbs', () => {
|
||||
assert.deepEqual(bus.PERSON_TASK_VERBS, [
|
||||
'tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(bus.PERSON_TASK_VERBS));
|
||||
});
|
||||
|
||||
test('every person task handler is declared in the vocabulary, even if the dispatch guard regresses', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
const handlers = [...source.matchAll(/^\s*case\s+(['"])(tasks\.[^'"]+)\1\s*:/gm)].map(match => match[2]);
|
||||
assert.deepEqual(handlers.sort(), [...bus.PERSON_TASK_VERBS].sort());
|
||||
});
|
||||
|
||||
test('every listed person task verb reaches its handler, while an unlisted verb is unknown', t => {
|
||||
const { broker, human } = fixture(t);
|
||||
assert.ok(Array.isArray(bus.PERSON_TASK_VERBS));
|
||||
for (const verb of bus.PERSON_TASK_VERBS) {
|
||||
// Missing required fields reaches the handler's field validation, not unknown-verb.
|
||||
assert.throws(() => broker.request(human, { verb, args: {} }), { code: 'invalid-request' }, verb);
|
||||
}
|
||||
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: {} }), { code: 'unknown-verb' });
|
||||
});
|
||||
|
||||
test('unknown person task verbs retain common argument and reader refusal precedence', t => {
|
||||
const { broker, human } = fixture(t);
|
||||
assert.throws(() => broker.request(human, { verb: 'tasks.foo', args: [] }), { code: 'invalid-request' });
|
||||
const reader = broker.bindReader({ business: 'acme' });
|
||||
assert.throws(() => broker.request(reader, { verb: 'tasks.foo' }), { code: 'read-only' });
|
||||
for (const verb of ['tasks.foo', 'tasks.create']) {
|
||||
assert.throws(() => broker.request(reader, { verb }), { code: 'read-only' }, verb);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/org-rights.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/org-rights.mjs";
|
||||
@@ -0,0 +1,129 @@
|
||||
// Accounts design draft 3, section 3 / M1 (#1873). Pure org policy only:
|
||||
// these tables do not grant access, resolve ownership, or inspect a caller.
|
||||
const record = entries => Object.freeze(Object.assign(Object.create(null), entries));
|
||||
const flag = grantable => Object.freeze({ grantable });
|
||||
|
||||
export const RIGHTS = record({
|
||||
'org.view': flag(true),
|
||||
'thread.read': flag(true),
|
||||
'thread.write': flag(true),
|
||||
'board.read': flag(true),
|
||||
'task.write': flag(true),
|
||||
decide: flag(true),
|
||||
'request.write': flag(false),
|
||||
'members.manage': flag(false),
|
||||
'org.setup': flag(false),
|
||||
'org.lifecycle': flag(false),
|
||||
});
|
||||
|
||||
// Templates are expanded into stored rights, never evaluated as live roles.
|
||||
// Only owner.json confers ownership; the owner bundle is never grantable.
|
||||
export const ROLE_RIGHTS = record({
|
||||
owner: Object.freeze(Object.keys(RIGHTS).sort()),
|
||||
member: Object.freeze(['board.read', 'org.view', 'thread.read']),
|
||||
collaborator: Object.freeze(['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write']),
|
||||
approver: Object.freeze(['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write']),
|
||||
});
|
||||
|
||||
// A reserved right still names the owner's operation. Null marks an explicit
|
||||
// owner-only operation with no member right. An unknown verb yields undefined.
|
||||
// Null-prototype records keep inherited property names from becoming rights.
|
||||
// M5 must separately exclude credential.mint from decide and assignees from
|
||||
// member task writes; these names alone cannot express action/field restrictions.
|
||||
export const VERB_RIGHTS = record({
|
||||
'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',
|
||||
'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',
|
||||
});
|
||||
|
||||
function refuse(code) {
|
||||
const error = new Error(code);
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
const known = right => typeof right === 'string' && Object.hasOwn(RIGHTS, right);
|
||||
|
||||
// Only thread writes need the collaborator scope level. Unknown rights must
|
||||
// refuse before the viewer fallback can run.
|
||||
export function rightLevel(right) {
|
||||
if (!known(right)) refuse('unknown-right');
|
||||
return right === 'thread.write' ? 'collaborator' : 'viewer';
|
||||
}
|
||||
|
||||
function memberRights(rights) {
|
||||
if (!Array.isArray(rights)) refuse('invalid-rights');
|
||||
for (const right of rights) {
|
||||
if (right === 'owner') refuse('right-not-grantable');
|
||||
if (!known(right)) refuse('unknown-right');
|
||||
if (!RIGHTS[right].grantable) refuse('right-not-grantable');
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize a membership write; never mutate its current rights or the role
|
||||
// template. Explicit removal of the compulsory org.view right always refuses.
|
||||
// When add and remove overlap, removal wins (except org.view, which refuses).
|
||||
export function normalizeRights(rights, options = {}) {
|
||||
if (options === null || typeof options !== 'object' || Array.isArray(options) ||
|
||||
Object.keys(options).some(key => !['add', 'remove'].includes(key))) refuse('invalid-rights');
|
||||
const add = Object.hasOwn(options, 'add') ? options.add : [];
|
||||
const remove = Object.hasOwn(options, 'remove') ? options.remove : [];
|
||||
memberRights(rights);
|
||||
memberRights(add);
|
||||
memberRights(remove);
|
||||
if (remove.includes('org.view')) refuse('right-not-removable');
|
||||
const result = new Set(['org.view', ...rights, ...add]);
|
||||
for (const right of remove) result.delete(right);
|
||||
return Object.freeze([...result].sort());
|
||||
}
|
||||
|
||||
// Subset check only: the caller must independently establish the fresh owner's
|
||||
// members.manage authority. A malformed or unknown granter set grants nothing.
|
||||
export function canGrant(granterRights, rights) {
|
||||
if (!Array.isArray(granterRights) || !Array.isArray(rights)) return false;
|
||||
for (const right of granterRights) if (!known(right)) return false;
|
||||
for (const right of rights) {
|
||||
if (!known(right) || !RIGHTS[right].grantable || !granterRights.includes(right)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
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 },
|
||||
'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.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 = {
|
||||
'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',
|
||||
'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',
|
||||
};
|
||||
|
||||
test('M1 the ten rights pin the grantable flags, including compulsory org.view', () => {
|
||||
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
|
||||
});
|
||||
|
||||
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',
|
||||
])].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'])));
|
||||
});
|
||||
Reference in new issue
Block a user