// Row 275 (193b1b, #1780, row 193 design 4.3, 8 and 9): the viewer level on the bus. A viewer grant // binds and reaches VIEWER_VERBS only; thread.create, thread.open and thread.send need a collaborator // grant on the project and instance. Board reads refuse every user, collaborator or viewer, and the // user-side governance pin names each item of the 5.7 user sentence. The fixture is user-principal's. 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 { fileURLToPath } from 'node:url'; import { Store } from '../src/store.mjs'; import { Broker, ACTIONS, USER_VERBS, VIEWER_VERBS, BOARD_VERBS, TESTER_VERBS, TESTER_ORGANIZATION_VERBS, PROJECT_FILE_VERBS, } from '../src/broker.mjs'; const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8')); const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }]; const grant = (over = {}) => ({ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over, }); function setup(t) { const root = mkdtempSync(join(tmpdir(), 'bus-viewer-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); const access = { doc: { accessVersion: 1, revision: 1, users: [], grants: [grant()] } }; const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, }, definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } }, }, }, access: () => access.doc, }); const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) }; const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); const code = (who, verb, args = {}) => { try { call(who, verb, args); } catch (e) { return e.code; } return null; }; const user = (name) => (caps[name] = b.bindUser({ business: 'demo', user: name })); const launch = (name, thread) => { caps[name] = b.bindLaunch({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread }); return call(name, 'role.claim'); }; return { b, store, access, caps, call, code, user, launch }; } // Two projects. Alice, a collaborator on p1, made thread A there and sent one message; then her grant // is lowered to viewer (the downgrade path, design 4.3). Bob is a viewer on p1 from the start. function viewers(t) { const ctx = setup(t); const p1 = ctx.call('human', 'project.create', { name: 'shared' }).id; const p2 = ctx.call('human', 'project.create', { name: 'private' }).id; ctx.access.doc.grants = [grant({ projects: [p1] })]; ctx.user('alice'); const A = ctx.call('alice', 'thread.create', { project: p1, name: 'alice work' }).id; const sent = ctx.call('alice', 'thread.send', { thread: A, body: 'hello' }).id; ctx.access.doc.grants = [ grant({ projects: [p1], level: 'viewer' }), grant({ id: 'g-b', user: 'bob', projects: [p1], level: 'viewer' }), ]; ctx.user('bob'); return { ...ctx, p1, p2, A, sent }; } test('275: VIEWER_VERBS is the ruled read list, inside USER_VERBS', () => { assert.deepEqual(VIEWER_VERBS, ['project.list', 'thread.list', 'thread.messages', 'names']); for (const v of VIEWER_VERBS) assert.ok(USER_VERBS.includes(v), v); }); test('275: a viewer binds, and each viewer verb answers with what its grant covers', (t) => { const { call, p1, A, sent } = viewers(t); call('human', 'name.set', { instance: 'assistant', name: 'ada' }); call('human', 'name.set', { instance: 'chief', name: 'cleo' }); for (const who of ['alice', 'bob']) { assert.deepEqual(call(who, 'project.list').map((p) => p.id), [p1], who); assert.deepEqual(call(who, 'names').map((n) => n.instance), ['assistant'], who); } // Alice's own old thread still lists and reads; bob has none. assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [A]); assert.deepEqual(call('alice', 'thread.messages', { thread: A }).map((m) => m.id), [sent]); assert.deepEqual(call('bob', 'thread.list'), []); }); // Mutant 6 (row 193 design 8): a viewer allowed thread.send. This test kills it. test('275: a viewer is refused thread.create, thread.open and thread.send access-refused, and nothing is stored', (t) => { const { store, code, p1, A } = viewers(t); const counts = () => ['messages', 'threads', 'thread_opens'].map((x) => store.get(`SELECT count(*) n FROM ${x}`).n); const before = counts(); for (const who of ['alice', 'bob']) assert.equal(code(who, 'thread.create', { project: p1, name: `${who} new` }), 'access-refused', `${who} create`); // Only alice owns a thread: #thread refuses a non-owner thread-not-owned before the level check // (duckworth-0166 B1), so open and send are asked on her own A. assert.equal(code('alice', 'thread.open', { thread: A }), 'access-refused', 'alice open'); assert.equal(code('alice', 'thread.send', { thread: A, body: 'still here?' }), 'access-refused', 'alice send'); assert.deepEqual(counts(), before); }); test('275: every verb off the user list refuses a viewer access-refused before its own checks', (t) => { const { b, caps } = viewers(t); const others = new Set([...ACTIONS, ...TESTER_VERBS, ...TESTER_ORGANIZATION_VERBS, ...PROJECT_FILE_VERBS, ...BOARD_VERBS, 'no.such.verb']); for (const v of USER_VERBS) others.delete(v); assert.ok(others.size > 40); for (const verb of others) assert.throws(() => b.request(caps.bob, { verb, args: { junk: true } }), { code: 'access-refused' }, verb); }); test('275: mixed grants, collaborator on one project and viewer on another, take the level per project', (t) => { const { call, code, access, user } = setup(t); const pa = call('human', 'project.create', { name: 'a' }).id; const pb = call('human', 'project.create', { name: 'b' }).id; access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb] })]; user('alice'); const B = call('alice', 'thread.create', { project: pb, name: 'b work' }).id; const inB = call('alice', 'thread.send', { thread: B, body: 'in b, before' }).id; access.doc.grants = [grant({ projects: [pa] }), grant({ id: 'g-2', projects: [pb], level: 'viewer' })]; const A = call('alice', 'thread.create', { project: pa, name: 'a work' }).id; assert.ok(A, 'create in the collaborator project works'); assert.equal(code('alice', 'thread.create', { project: pb, name: 'b more' }), 'access-refused', 'create in the viewer project'); assert.deepEqual(call('alice', 'project.list').map((p) => p.id), [pa, pb]); assert.deepEqual(call('alice', 'thread.list').map((x) => x.id), [B, A]); assert.ok(call('alice', 'thread.send', { thread: A, body: 'in a' }).id); assert.equal(code('alice', 'thread.send', { thread: B, body: 'in b' }), 'access-refused'); assert.equal(code('alice', 'thread.open', { thread: B }), 'access-refused'); assert.deepEqual(call('alice', 'thread.messages', { thread: B }).map((m) => m.id), [inB]); }); // Mutant 10 (row 193 design 8): a user allowed `trail`. This test kills it; R3's exact USER_VERBS // pin in user-principal.test.mjs fails too. Carol is a collaborator, so the refusal isn't the level's. test('275: board reads refuse users and viewers access-refused', (t) => { const { code, access, user, p1, A } = viewers(t); assert.deepEqual(BOARD_VERBS, ['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']); access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })]; user('carol'); for (const who of ['alice', 'bob', 'carol']) { for (const verb of [...BOARD_VERBS, ...PROJECT_FILE_VERBS, 'limits', 'plugins', 'instances', 'routes']) assert.equal(code(who, verb, verb === 'trail' ? { subject: A } : {}), 'access-refused', `${who} ${verb}`); } }); // Row 193's user sentence, from the user's side (mos-1023 item 2, design 8): "The business owner sees // your thread's agent's decisions, inbox entries, claims, tasks and project files, but not your // messages." The owner side is message-filter.test.mjs's pin. Here each item the owner sees is a // board read the user is refused, at either level, while the user still reads its own thread. test('governance pin, user side: decisions, inbox entries, claims, tasks and project files are the owner\'s, the user reads its messages', (t) => { const { call, code, access, user, launch, p1 } = viewers(t); access.doc.grants = [...access.doc.grants, grant({ id: 'g-c', user: 'carol', projects: [p1] })]; user('carol'); const C = call('carol', 'thread.create', { project: p1, name: 'carol work' }).id; const sent = call('carol', 'thread.send', { thread: C, body: 'please plan it' }).id; launch('a', C); const d = call('a', 'decision.raise', { action: 'deploy', question: 'Ship?', options, recommendation: 'yes', blocking: false }).id; assert.ok(call('human', 'inbox').some((x) => x.id === d), 'the owner sees the raise'); const items = [ ['decisions', 'decisions', { list: 'recent' }], ['inbox entries', 'inbox', {}], ['claims', 'agents', {}], ['claims', 'thread.claims', {}], ['tasks', 'tasks', {}], ['tasks', 'state', {}], ['project files', 'project.files', { project: 'stack' }], ['project files', 'project.file', { project: 'stack', path: 'docs/plan.md' }], ]; for (const who of ['carol', 'bob']) for (const [item, verb, args] of items) assert.equal(code(who, verb, args), 'access-refused', `${item}: ${who} ${verb}`); assert.deepEqual(call('carol', 'thread.messages', { thread: C }).map((m) => m.id), [sent], 'messages: the user reads its own'); });