// D1 (#1873, readiness audit P0-2): on a Console started with --dev-local-owner, an organization's // autonomy page read `writable:false` and refused every owner save. The organization options built in // cli.mjs carried no writer the autonomy route could use. This runs the real cli.mjs serve // --dev-local-owner against a real organization record; only the bus transports cli.mjs builds are // replaced (loader hook), so no bus or human proof is involved. import test from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs'; import { request } from 'node:http'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { tmp, fixture } from '../../cli/tests/helpers.mjs'; import { fakeOidc } from './helpers/fake-oidc.mjs'; import { ownerAccounts } from './helpers/owner-accounts.mjs'; import { SESSION_COOKIE } from '../src/sso.mjs'; import { createOrg } from '../../organization/src/org-service.mjs'; import { orgPaths } from '../../cli/src/config.mjs'; import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs'; const AUTONOMY = { revision: 3, autonomy: { default: 'near' } }; const HOST = 'console.example.test', ORIGIN = `https://${HOST}`; // `sso`: a deployed Console instead (--sso --send, a fake OP on loopback, the owner signed in). async function consoleFor(t, { sso = false } = {}) { const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test'); mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir); chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700); writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' })); const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack'; const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc); const log = join(root, 'calls.jsonl'); writeFileSync(log, ''); const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href; // The console transport answers the autonomy verbs from fixed rows and logs (kind, business, socket, verb, args, subject). const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs'); writeFileSync(busShim, `import { appendFileSync } from 'node:fs'; import { BusReadError } from ${JSON.stringify(busURL)}; export * from ${JSON.stringify(busURL)}; const record = (kind, o, verb, args, extra = {}) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args, ...extra }) + '\\n'); export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return verb === 'plugins' ? { plugins: { channel: ['cli', 'webui'] } } : []; }; export const consoleCall = o => async (verb, args, { subject }) => { record('console', o, verb, args, { subject }); if (verb === 'autonomy.get.console') return { revision: 3, autonomy: { default: 'in' } }; if (verb === 'autonomy.set.console') return { revision: 4, autonomy: args.autonomy, receipt: { action: 'autonomy.set', revision: 4 } }; if (['autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console'].includes(verb)) return { answered: verb }; throw new BusReadError('unavailable', 'fixture: ' + verb); }; `); writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`); writeFileSync(hook, `import { registerHooks } from 'node:module'; registerHooks({ resolve(specifier, context, next) { if (context.parentURL?.endsWith('/webui/src/cli.mjs')) { if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true }; if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true }; } return next(specifier, context); } }); `); const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` }; let op = null, a = null; if (sso) { op = await fakeOidc(); t.after(() => op.close()); a = op.issuer('a'); const provider = { id: a.name, issuer: a.issuer, clientId: a.clientId, clientSecret: { slot: a.name }, algs: [...new Set(a.keys.map(k => k.alg))], subjects: [{ sub: `user-${a.name}`, name: `person-${a.name}` }] }; const config = JSON.parse(readFileSync(env.MOSAIC_CONFIG, 'utf8')); writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...config, console: { sso: { publicOrigin: ORIGIN, providers: [provider] } } })); mkdirSync(join(configDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 }); writeFileSync(join(configDir, 'secrets', 'mosaic-stack', a.name), `${a.clientSecret}\n`, { mode: 0o600 }); writeFileSync(join(configDir, 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [provider] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 0o600 }); writeFileSync(join(root, 'ca.pem'), op.cert); env.NODE_EXTRA_CA_CERTS = join(root, 'ca.pem'); } const mode = sso ? ['--sso', '--send'] : ['--dev-local-owner']; const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...mode], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c); t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); }); for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) { if (child.exitCode !== null) break; await new Promise(r => setTimeout(r, 50)); } const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(out); assert.ok(base, `the Console did not start:\n${out}`); const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line)); return { port: Number(base[2]), calls, org, op, a, rootSocket: join(f.dataRoot, 'bus', 'broker.sock') }; } function call(port, path, { method = 'GET', body = null, headers = {}, host = `127.0.0.1:${port}`, origin = `http://${host}` } = {}) { const h = { host, ...(method === 'GET' ? {} : { origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers }; for (const k of Object.keys(h)) if (h[k] === null) delete h[k]; return new Promise((resolve, reject) => { const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => { let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; }); res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, headers: res.headers, body: json }); }); }); r.on('error', reject); r.end(body ?? undefined); }); } test('the dev-local owner reads an organization autonomy page as writable and a save reaches that organization bus', { timeout: 60000 }, async t => { const { port, calls, org } = await consoleFor(t); const subject = { provider: DEV_OWNER.provider, iss: DEV_OWNER.iss, sub: DEV_OWNER.sub }; const read = await call(port, '/api/orgs/alpha/autonomy'); assert.equal(read.status, 200, JSON.stringify(read.body)); assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true }); const saved = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY) }); assert.equal(saved.status, 200, JSON.stringify(saved.body)); assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true }); const socket = orgPaths(org.root).socket; assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket: s, verb, args, subject: who }) => ({ business, socket: s, verb, args, subject: who })), [ { business: 'alpha', socket, verb: 'autonomy.get.console', args: {}, subject }, { business: 'alpha', socket, verb: 'autonomy.set.console', args: AUTONOMY, subject }, ]); // Review, confirm and reverse go to the same organization bus as the owner (D1 fix round). for (const [step, body, verb] of [['review', { decisionClass: 'merge' }, 'autonomy.review.console'], ['confirm', { id: 'd-1', confirm: true, reason: 'checked' }, 'decision.confirm.console'], ['reverse', { id: 'd-1' }, 'decision.reverse.console']]) { const r = await call(port, `/api/orgs/alpha/autonomy/${step}`, { method: 'POST', body: JSON.stringify(body) }); assert.equal(r.status, 200, `${step}: ${JSON.stringify(r.body)}`); assert.deepEqual(r.body, { answered: verb, writable: true }); assert.deepEqual(calls().filter(c => c.kind === 'console').at(-1), { kind: 'console', business: 'alpha', socket, verb, args: body, subject }); } // The write keeps the route's own checks: another site saves nothing. const before = calls().length; for (const headers of [{ origin: 'http://evil.example' }, { 'sec-fetch-site': 'cross-site' }]) { const r = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), headers }); assert.equal(r.status, 403, JSON.stringify(headers)); } assert.equal(calls().length, before); // Request intake and project remote writes stay unavailable for the organizations release (README inventory). const config = (await call(port, '/api/orgs/alpha/config')).body; assert.equal(config.requestWrites, false); assert.equal(config.projectRemoteWrites, false); }); test('the organization autonomy writer passes the four autonomy verbs and nothing else', async () => { // Imported here so the other tests in this file also run against BASE, which has no such export. const { AUTONOMY_WRITE_VERBS, autonomyCaller } = await import('../src/autonomy-http.mjs'); const seen = []; const write = autonomyCaller(async (verb, args, options) => { seen.push(verb); return { verb, args, options }; }); for (const verb of AUTONOMY_WRITE_VERBS) assert.equal((await write(verb, {}, { subject: {} })).verb, verb); assert.deepEqual(AUTONOMY_WRITE_VERBS, ['autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console']); for (const verb of ['autonomy.get.console', 'request.draft.console', 'project.remote.set.console', 'decision.resolve.console', 'hire.start.console', 'prd.revoke.console']) { await assert.rejects(write(verb, {}, { subject: {} }), /does not call/); } assert.deepEqual(seen, AUTONOMY_WRITE_VERBS); }); // Without --dev-local-owner nothing changed (D1 acceptance). A deployed Console (--sso --send) has no // organization routes, and its own autonomy route still reads and writes through the instance's request // transport (the root bus, no business), for its signed-in owner, exactly as before D1. test('a deployed --sso --send Console is unchanged: no organization autonomy route, and the root route keeps the request transport', { timeout: 60000 }, async t => { const { port, calls, op, a, rootSocket } = await consoleFor(t, { sso: true }); const login = await call(port, '/auth/login', { host: HOST }); assert.equal(login.status, 303); const loginCookie = [login.headers['set-cookie'] ?? []].flat()[0].split(';')[0]; const granted = op.grant(a, login.headers.location); const state = new URL(login.headers.location).searchParams.get('state'); const signed = await call(port, `/auth/callback?${new URLSearchParams({ code: granted.code, state })}`, { host: HOST, headers: { cookie: loginCookie } }); const cookie = [signed.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0]; assert.ok(cookie, `no session: ${signed.status}`); const orgRead = await call(port, '/api/orgs/alpha/autonomy', { host: HOST, headers: { cookie } }); assert.equal(orgRead.status, 404); assert.deepEqual(orgRead.body, { error: 'not found' }); const orgSave = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } }); assert.equal(orgSave.status, 403); assert.deepEqual(orgSave.body, { error: 'read-only', message: 'the Console preview is read-only' }); const read = await call(port, '/api/autonomy', { host: HOST, headers: { cookie } }); assert.equal(read.status, 200, JSON.stringify(read.body)); assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true }); const saved = await call(port, '/api/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } }); assert.equal(saved.status, 200, JSON.stringify(saved.body)); assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true }); const subject = { provider: a.name, iss: a.issuer, sub: `user-${a.name}` }; assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket, verb, subject: who }) => ({ business, socket, verb, subject: who })), [ { business: null, socket: rootSocket, verb: 'autonomy.get.console', subject }, { business: null, socket: rootSocket, verb: 'autonomy.set.console', subject }, ]); });