// U1 (#1873, PRD 1.20 REQ-WS-1..8): the cross-organization read behind GET /api/workspace. // Merge, membership, partial failure, timeout, sort and filter, the copied decide command, the // route through startServer, and the assistant-read stop. Every bus here is a fake options // object or a real org registry in this test's own scratch; no test reaches a live bus. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as fs from 'node:fs/promises'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createOrg, showOrg, templateRender } from '../../organization/src/org-service.mjs'; import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; import { organizationConsole } from '../src/organizations.mjs'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { parseWorkspaceQuery, readWorkspace, workspaceHTTP, WorkspaceQueryError, WORKSPACE_POLL_MS } from '../src/workspace-read.mjs'; import * as broker from '../../bus/src/broker.mjs'; const principal = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' }; const NOW = Date.parse('2026-11-09T13:00:00.000Z'); const now = () => NOW; const never = () => new Promise(() => {}); // One org's bus as the org pages see it: options().bus and options().chat. `over` replaces reads. function options(data, over = {}) { const value = name => (over[name] ? over[name]() : Promise.resolve(data[name] ?? (['usage', 'limits'].includes(name) ? {} : []))); return { bus: { inbox: () => value('inbox'), agents: () => value('agents'), 'thread.claims': () => value('claims'), tasks: () => value('tasks'), usage: () => value('usage'), limits: () => value('limits'), decisions: () => value('decisions') }, chat: { threads: () => value('threads'), projects: () => value('projects') }, }; } const healthy = async () => ({ healthy: true, pid: 4242, boot: null }); function member(id, data, { status = healthy, over = {}, doc = {} } = {}) { return { id, doc: { appearance: { theme: `${id}-theme` }, ...doc }, status, options: () => options(data, over) }; } const organizationsOf = (...members) => ({ members: async () => members, list: async () => [], resolve: async () => null }); const ACME = { projects: [ { id: 'p-site', repo: 'site', name: 'Website', archived: false, at: '2026-11-09T10:00:00.000Z' }, { id: 'p-old', repo: 'old', name: 'Old brochure', archived: true, at: '2026-10-01T00:00:00.000Z' }, ], tasks: [ { task_ref: 'vk:1', project_key: 'site', priority: 2, at: '2026-11-09T11:00:00.000Z', fields: { title: 'Hero copy', done: false, assignees: [7, 99], bucket: 'Doing', labels: ['web'] } }, { task_ref: 'vk:2', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Logo', done: true } }, { task_ref: 'vk:3', project_key: 'site', at: '2026-11-08T00:00:00.000Z', fields: { title: 'Removed', gone: true } }, ], agents: [{ role: 'coder', holder_run: 'run-1', harness: 'claude', state: 'waiting', at: '2026-11-09T09:00:00.000Z', inputNeeded: [{ task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z' }], stranded: [] }], claims: [{ thread: 't-1', instance: 'assistant', holder_run: 'run-2', harness: 'claude', at: '2026-11-09T08:00:00.000Z' }], threads: [{ id: 't-1', project: 'p-site' }], inbox: [{ id: 'd1234567', question: 'Ship the hero?', class: 'gated', blocking: true, raised_by_role: 'coder', task_ref: 'vk:1', at: '2026-11-09T12:00:00.000Z', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] }], usage: { total: { tokens: 1200 } }, limits: { tokens: [{ scope: 'org', used: 1200, cap: 5000 }] }, decisions: [{ at: '2026-11-09T00:00:00.000Z', closed: { op: 'resolved', at: '2026-11-09T00:10:00.000Z' } }], }; const ACME_DOC = { tracker: { kind: 'vikunja' }, roles: { coder: { tracker: { botId: 7 } }, assistant: {} } }; const BETA = { projects: [{ id: 'p-shop', repo: 'shop', name: 'Shop', archived: false, at: '2026-11-07T00:00:00.000Z' }], tasks: [{ task_ref: 'local:1', project_key: 'shop', at: '2026-11-07T00:00:00.000Z', fields: { title: 'Price list', done: false } }], }; test('merge: every item from two buses carries its org, and the snapshot names both orgs', async () => { const body = await readWorkspace({ organizations: organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA)), session: principal, now }); assert.equal(body.pollMs, WORKSPACE_POLL_MS); assert.deepEqual(body.summary, { read: 2, of: 2 }); assert.deepEqual(body.orgs.map(o => [o.org, o.state, o.tracker, o.theme]), [['acme', 'ok', 'vikunja', 'acme-theme'], ['beta', 'ok', 'built-in', 'beta-theme']]); assert.deepEqual(body.orgs[0].roles, ['assistant', 'coder']); for (const key of ['projects', 'sessions', 'inbox', 'tasks']) for (const row of body[key]) assert.ok(['acme', 'beta'].includes(row.org), `${key} row without org`); assert.deepEqual(body.metrics.orgs.map(m => m.org), ['acme', 'beta']); // Archived projects are hidden by default; the default sort is newest activity first. assert.deepEqual(body.projects.map(p => `${p.org}/${p.key}`), ['acme/site', 'beta/shop']); const site = body.projects[0]; assert.deepEqual({ ...site }, { org: 'acme', id: 'p-site', key: 'site', name: 'Website', status: 'active', state: 'working', git: null, board: null, sessions: { working: 1, waiting: 1, idle: 0 }, openTasks: 1, needsYou: 1, lastActivity: '2026-11-09T12:00:00.000Z' }); const role = body.sessions.find(s => s.kind === 'role'), thread = body.sessions.find(s => s.kind === 'thread'); assert.equal(role.org, 'acme'); assert.equal(role.role, 'coder'); assert.equal(role.state, 'waiting'); assert.equal(role.inputNeeded, 1); assert.deepEqual(role.task, { ref: 'vk:1', title: 'Hero copy' }); assert.equal(role.project.key, 'site'); assert.equal(role.projectFrom, 'task'); assert.equal(thread.thread, 't-1'); assert.equal(thread.project.key, 'site'); assert.equal(thread.projectFrom, 'thread'); const ask = body.inbox[0]; assert.equal(ask.org, 'acme'); assert.equal(ask.raisedBy, 'coder'); assert.deepEqual(ask.session, { role: 'coder', run: 'run-1' }); assert.equal(ask.project.key, 'site'); // The gone task is dropped; only bot ids from the org file become role names. assert.deepEqual(body.tasks.map(t => `${t.org}/${t.ref}`), ['acme/vk:1', 'acme/vk:2', 'beta/local:1']); assert.deepEqual(body.tasks[0].assignees, ['coder']); assert.equal(body.tasks[0].bucket, 'Doing'); assert.equal(body.tasks[2].tracker, 'built-in'); assert.deepEqual(body.metrics.orgs[0], { org: 'acme', needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 }, openTasks: 1, doneThisWeek: 1, projects: { active: 1, archived: 1 }, usage: { tokens: 1200 }, limits: [{ scope: 'org', used: 1200, cap: 5000 }], decisionWaitMedianMs: 600000 }); assert.deepEqual(body.metrics.totals, { needsYou: 1, oldestNeedsYouAt: '2026-11-09T12:00:00.000Z', sessions: { working: 0, waiting: 1, idle: 0, live: 1 }, openTasks: 2, doneThisWeek: 1, projects: { active: 2, archived: 1 } }); }); test('SP6 board: the org head carries its board, each project row its project board, an invalid org none', async () => { const ORG_BOARD = { columns: [{ state: 'done', label: 'Solved' }, { state: 'todo', label: 'New' }, { state: 'in-progress', label: 'Working' }, { state: 'in-review', label: 'Waiting' }, { state: 'blocked', label: 'On hold' }], layout: 'list' }; const SITE_BOARD = { columns: [{ state: 'todo', label: 'Draft' }, { state: 'in-progress', label: 'Editing' }, { state: 'in-review', label: 'Proof' }, { state: 'blocked', label: 'Held' }, { state: 'done', label: 'Live' }], layout: 'kanban' }; const doc = { ...ACME_DOC, board: ORG_BOARD, projects: { site: { root: '/x', board: SITE_BOARD }, old: { root: '/y' } } }; const broken = { id: 'gamma', invalid: true, reason: 'organization file is invalid', doc: { board: ORG_BOARD } }; const body = await readWorkspace({ organizations: organizationsOf(member('acme', ACME, { doc }), member('beta', BETA), broken), session: principal, now }); assert.deepEqual(body.orgs.map(o => [o.org, o.board]), [['acme', ORG_BOARD], ['beta', null], ['gamma', null]]); assert.deepEqual(body.projects.map(p => [`${p.org}/${p.key}`, p.board]).sort(), [['acme/site', SITE_BOARD], ['beta/shop', null]]); }); test('sections: only the asked sections are read and returned', async () => { const asked = []; const tracked = member('acme', ACME, { over: Object.fromEntries(['inbox', 'agents', 'claims', 'threads', 'tasks', 'projects', 'usage', 'limits', 'decisions'].map(n => [n, () => { asked.push(n); return Promise.resolve(ACME[n] ?? (['usage', 'limits'].includes(n) ? {} : [])); }])) }); const body = await readWorkspace({ organizations: organizationsOf(tracked), session: principal, query: parseWorkspaceQuery('sections=tasks'), now }); assert.deepEqual(Object.keys(body).filter(k => ['projects', 'sessions', 'inbox', 'tasks', 'metrics'].includes(k)), ['tasks']); assert.deepEqual(asked.sort(), ['inbox', 'projects', 'tasks']); }); test('membership: a foreign org is never read or named; a broken own org shows as invalid; options get the timeout', async t => { const root = await fs.mkdtemp(join(tmpdir(), 'wsr-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal }; await fs.mkdir(ctx.configDir, { mode: 0o700 }); await fs.mkdir(ctx.dataRoot, { mode: 0o700 }); for (const id of ['alpha', 'beta', 'private-tenant']) await createOrg(ctx, id, await templateRender(id)); await fs.writeFile(join(ctx.dataRoot, 'org-runtime', 'private-tenant', 'owner.json'), JSON.stringify({ ...principal, sub: 'different-owner' })); const statusAsked = [], optionsAsked = []; const orgs = organizationConsole({ ctx, listOrgs, showOrg, resolveOrg, orgPaths: () => ({}), busStatus: async (_ctx, id) => { statusAsked.push(id); return id === 'beta' ? { healthy: false, pid: null, boot: { ok: false, at: '2026-11-09T12:59:00.000Z', reason: `config missing at ${ctx.dataRoot}/org-runtime/beta/x.json` } } : { healthy: true, pid: 7 }; }, options: o => { optionsAsked.push({ id: o.id, timeoutMs: o.timeoutMs }); return options(o.id === 'alpha' ? BETA : {}); } }); const body = await readWorkspace({ organizations: orgs, session: principal, timeoutMs: 1500, now, ctx }); assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['alpha', 'ok'], ['beta', 'down']]); assert.equal(JSON.stringify(body).includes('private-tenant'), false); assert.deepEqual(statusAsked.sort(), ['alpha', 'beta']); assert.deepEqual(optionsAsked, [{ id: 'alpha', timeoutMs: 1500 }]); // The boot refusal is shown with the data root made relative. assert.equal(body.orgs[1].reason, 'the bus refused to start: config missing at /org-runtime/beta/x.json'); assert.equal(JSON.stringify(body).includes(root), false); // A different principal sees none of these orgs. const outsider = await readWorkspace({ organizations: orgs, session: { ...principal, sub: 'outsider' }, now, ctx }); assert.deepEqual(outsider.orgs, []); // The owner of a broken org sees it as invalid, with no host path. await fs.unlink(join(ctx.configDir, 'businesses', 'alpha.json')); const broken = await readWorkspace({ organizations: orgs, session: principal, now, ctx }); assert.equal(broken.orgs[0].org, 'alpha'); assert.equal(broken.orgs[0].state, 'invalid'); assert.equal(broken.orgs[0].reason.includes(ctx.configDir), false); assert.deepEqual(broken.summary, { read: 0, of: 2 }); }); test('partial failure: down, starting and a status that throws each get a state and a time; the others return', async () => { const ctx = { dataRoot: '/srv/fake-data', configDir: '/srv/fake-config' }; const body = await readWorkspace({ now, ctx, session: principal, organizations: organizationsOf( member('acme', ACME, { doc: ACME_DOC }), member('down', BETA, { status: async () => ({ healthy: false, pid: null, boot: { ok: true, at: '2026-11-09T12:00:00.000Z' } }) }), member('starting', BETA, { status: async () => ({ healthy: false, pid: 991, boot: null }) }), member('stale', BETA, { status: async () => { throw new Error('host state names /srv/fake-data/org-runtime/stale/bus-host and /etc/secret-place'); } }), member('reads-fail', BETA, { over: { tasks: () => Promise.reject(Object.assign(new Error('nope'), { code: 'bus-unavailable' })), projects: () => Promise.reject(new Error('raw text /etc/x')) } }), ) }); const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o])); assert.equal(byOrg.acme.state, 'ok'); assert.equal(byOrg.down.state, 'down'); assert.equal(byOrg.down.reason, 'the bus is not running'); assert.equal(byOrg.starting.state, 'starting'); assert.equal(byOrg.starting.reason, 'the bus host is running but does not answer yet'); assert.equal(byOrg.stale.state, 'down'); assert.equal(byOrg.stale.reason, 'host state names /org-runtime/stale/bus-host and '); // A read that fails inside an answering bus is that read's error code, never its message. assert.equal(byOrg['reads-fail'].state, 'ok'); assert.deepEqual(byOrg['reads-fail'].errors, { tasks: 'bus-unavailable', projects: 'read-failed' }); for (const o of body.orgs) { assert.equal(o.at, new Date(NOW).toISOString()); assert.equal(typeof o.readMs, 'number'); } assert.deepEqual(body.summary, { read: 2, of: 5 }); assert.ok(body.projects.every(p => p.org === 'acme')); }); test('timeout: a bus that never answers, or whose state is never read, is marked timeout within the bound', async () => { const started = Date.now(); const body = await readWorkspace({ timeoutMs: 150, session: principal, organizations: organizationsOf( member('acme', ACME, { doc: ACME_DOC }), member('slow', BETA, { over: { tasks: never } }), member('stuck', BETA, { status: never }), ) }); const elapsed = Date.now() - started; assert.ok(elapsed < 1500, `took ${elapsed} ms`); const byOrg = Object.fromEntries(body.orgs.map(o => [o.org, o])); assert.equal(byOrg.acme.state, 'ok'); assert.equal(byOrg.slow.state, 'timeout'); assert.equal(byOrg.slow.reason, 'the bus did not answer within 150 ms'); assert.equal(byOrg.stuck.state, 'timeout'); assert.equal(byOrg.stuck.reason, 'the bus state was not read within 150 ms'); assert.equal(body.timeoutMs, 150); assert.ok(body.tasks.every(t => t.org === 'acme')); }); test('query: strict keys and values', () => { const q = parseWorkspaceQuery('?sections=projects,tasks&org=acme,beta&status=active,archived&state=working&name=Web&sort=name&project=acme:site'); assert.deepEqual([...q.sections], ['projects', 'tasks']); assert.deepEqual([...q.orgs], ['acme', 'beta']); assert.deepEqual([...q.status], ['active', 'archived']); assert.deepEqual([...q.state], ['working']); assert.equal(q.name, 'web'); assert.equal(q.sort, 'name'); assert.equal(q.order, 'asc'); assert.deepEqual(q.project, { org: 'acme', key: 'site' }); assert.equal(parseWorkspaceQuery('').order, 'desc'); for (const bad of ['secton=projects', 'org=acme&org=beta', 'sections=', 'sections=chat', 'org=Bad Org', 'status=paused', 'state=busy', `name=${'x'.repeat(201)}`, 'name=%20', 'sort=size', 'order=up', 'project=acme', 'project=acme:a/b']) assert.throws(() => parseWorkspaceQuery(bad), WorkspaceQueryError, bad); }); test('sort and filter: projects by org, status, state, name and activity; tasks by org and project', async () => { const organizations = organizationsOf(member('acme', ACME, { doc: ACME_DOC }), member('beta', BETA)); const read = search => readWorkspace({ organizations, session: principal, now, query: parseWorkspaceQuery(search) }); const keys = rows => rows.map(p => `${p.org}/${p.key}`); assert.deepEqual(keys((await read('status=active,archived')).projects), ['acme/site', 'beta/shop', 'acme/old']); assert.deepEqual(keys((await read('status=active,archived&sort=activity&order=asc')).projects), ['acme/old', 'beta/shop', 'acme/site']); assert.deepEqual(keys((await read('status=active,archived&sort=name')).projects), ['acme/old', 'beta/shop', 'acme/site']); assert.deepEqual(keys((await read('status=active,archived&sort=org&order=desc')).projects), ['beta/shop', 'acme/site', 'acme/old']); assert.deepEqual(keys((await read('status=active,archived&sort=status')).projects), ['acme/site', 'beta/shop', 'acme/old']); assert.deepEqual(keys((await read('status=archived')).projects), ['acme/old']); assert.deepEqual(keys((await read('state=none')).projects), ['beta/shop']); assert.deepEqual(keys((await read('name=SHO')).projects), ['beta/shop']); const onlyBeta = await read('org=beta'); assert.deepEqual(onlyBeta.orgs.map(o => o.org), ['beta']); assert.deepEqual(keys(onlyBeta.projects), ['beta/shop']); // An org filter naming an org the viewer is not in returns nothing, not an error. assert.deepEqual((await read('org=nobody')).orgs, []); assert.deepEqual((await read('project=acme:site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']); assert.deepEqual((await read('project=acme:p-site')).tasks.map(t => t.ref), ['vk:1', 'vk:2']); assert.deepEqual((await read('project=beta:site')).tasks, []); }); test('decide: a copied command names the org; an unsafe id or key gets none', async () => { const data = { ...ACME, inbox: [...ACME.inbox, { id: 'd99 ; rm -rf ~', question: 'x', options: [{ key: 'ok', text: 'ok' }] }, { id: 'e7654321', question: 'y', options: [{ key: 'a b', text: 'a b' }] }] }; const body = await readWorkspace({ organizations: organizationsOf(member('acme', data, { doc: ACME_DOC })), session: principal, now, query: parseWorkspaceQuery('sections=inbox') }); const byId = Object.fromEntries(body.inbox.map(d => [d.id, d])); assert.deepEqual(byId.d1234567.decide, [{ key: 'yes', command: 'mosaic decide d1234567 yes --org acme' }, { key: 'no', command: 'mosaic decide d1234567 no --org acme' }]); assert.deepEqual(byId['d99 ; rm -rf ~'].decide, [{ key: 'ok', command: null }]); assert.deepEqual(byId.e7654321.decide, [{ key: 'a b', command: null }]); // Gated first, then oldest first. assert.equal(body.inbox[0].id, 'd1234567'); }); function fakeRes() { return { status: null, body: null, writeHead(status) { this.status = status; }, end(text) { this.body = JSON.parse(text); } }; } test('handler: non-GET, no registry, non-owner, bad query and an unreadable registry', async () => { const organizations = organizationsOf(member('acme', ACME)); const run = async (opts, method = 'GET') => { const res = fakeRes(); await workspaceHTTP({ method }, res, { session: principal, owner: true, organizations, ...opts }); return res; }; assert.equal((await run({}, 'POST')).status, 405); assert.deepEqual((await run({ organizations: null })).body, { error: 'org-unavailable' }); // A granted user or the tester reaches the hook with owner false and no owner session. for (const who of [{ owner: false }, { session: null }]) { const res = await run(who); assert.equal(res.status, 403); assert.deepEqual(res.body, { error: 'owner-only', message: 'the cross-organization view is the owner\'s' }); } const bad = await run({ search: '?org=acme&org=acme' }); assert.equal(bad.status, 400); assert.equal(bad.body.error, 'invalid-request'); const logged = []; const down = await run({ organizations: { members: async () => { throw new Error('/srv/x/org-runtime.json: Unexpected token'); } }, log: line => logged.push(line) }); assert.equal(down.status, 503); assert.deepEqual(down.body, { error: 'org-unavailable', message: 'Organization registry could not be read' }); assert.deepEqual(logged, ['workspace: organization registry could not be read']); }); async function serve(t, organizations, sso = createDevOwner()) { const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, organizations, log: () => {}, workspaceTimeoutMs: 200 }); t.after(() => new Promise(r => { s.close(r); s.closeIdleConnections(); })); return `http://127.0.0.1:${s.address().port}`; } test('route: the dev owner reads GET /api/workspace; a granted user gets owner-only; the timeout option applies', async t => { const seen = []; const organizations = { list: async () => [], resolve: async () => null, members: async (session, opts) => { seen.push({ sub: session.sub, timeoutMs: opts.timeoutMs }); return [member('acme', ACME, { doc: ACME_DOC }), member('slow', BETA, { status: never })]; } }; const base = await serve(t, organizations); const response = await fetch(`${base}/api/workspace?sections=projects,metrics`), body = await response.json(); assert.equal(response.status, 200); assert.equal(response.headers.get('cache-control'), 'no-store'); assert.deepEqual(body.orgs.map(o => [o.org, o.state]), [['acme', 'ok'], ['slow', 'timeout']]); assert.equal(body.timeoutMs, 200); assert.deepEqual(seen, [{ sub: 'local-dev-owner', timeoutMs: 200 }]); assert.equal((await fetch(`${base}/api/workspace?bogus=1`)).status, 400); // POST has no route here: with no Origin the dev fence refuses it, and with the page's Origin the // organizations fence answers as for any unknown /api path. assert.equal((await fetch(`${base}/api/workspace`, { method: 'POST' })).status, 403); const post = await fetch(`${base}/api/workspace`, { method: 'POST', headers: { origin: base, 'content-type': 'application/json' }, body: '{}' }); assert.equal(post.status, 404); assert.deepEqual(await post.json(), { error: 'org-unavailable' }); // A granted user's session (session.user set) is fenced before any org is read. const granted = { ...createDevOwner(), session: () => ({ ...principal, sub: 'alice-sub', user: 'alice', name: 'Alice' }) }; const userBase = await serve(t, organizations, granted); const refused = await fetch(`${userBase}/api/workspace`); assert.equal(refused.status, 403); assert.equal((await refused.json()).error, 'owner-only'); assert.equal(seen.length, 1); }); test('assistant read is designed, not built: no workspace broker verb, assistant authority unchanged', () => { const role = JSON.parse(readFileSync(new URL('../../../roles/assistant.json', import.meta.url), 'utf8')); assert.deepEqual(role.authority, { withinRole: ['message.send', 'record.write'], crossRole: [] }); const verbs = new Set(Object.values(broker).filter(Array.isArray).flat().filter(v => typeof v === 'string')); assert.ok(verbs.has('inbox'), 'the verb lists were read'); assert.deepEqual([...verbs].filter(v => /^(workspace|orgs?)\b/.test(v)), []); });