// F2 (PRD 1.20 draft REQ-SETUP-2): the onboarding interview read and the north-star approval route. // The approval has the project save's boundary and writes only through the human save verb; in an // organization it is the one project write past the org fence. Every transport here is a fake. import test from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { startServer, userRoute } from '../src/serve.mjs'; import { BusReadError } from '../src/bus.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { close } from './fixture.mjs'; import { createSsoSession } from './helpers/sso-session.mjs'; import { testerFixture } from './tester-fixture.mjs'; import { TESTER_ORIGIN } from '../src/tester-socket.mjs'; import { interviewMessage, loadInterviewTemplate, northStarTemplateText, SPEC_PATH } from '../../project/src/onboarding.mjs'; const TEMPLATE = loadInterviewTemplate(); const DRAFT = TEMPLATE.drafts.northStar; const S1 = 'a'.repeat(40), D1 = 'b'.repeat(40), S2 = 'c'.repeat(40); const TEXT = '# North star\n\nOnboard new organizations in one sitting.\n'; const payload = { project: 'stack', draft: D1, base: S1 }; const INSTANCES = { revision: 'f'.repeat(64), definitions: {}, instances: { coder: { definition: 'coder' }, helper: { definition: 'assistant' }, assistant: { definition: 'assistant', vars: { 'persona.name': 'Ada' } }, } }; function transports({ read, save, instances } = {}) { const calls = { file: [], save: [], instances: 0 }; return { calls, projectFile: async args => { calls.file.push(args); if (read) return read(args); return args.path === DRAFT ? { path: DRAFT, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T00:00:00Z' } : { path: args.path, head: D1, commit: S1, content: northStarTemplateText(), author: 'jason', at: '2026-11-08T00:00:00Z' }; }, projectSave: async args => { calls.save.push(args); return save ? save(args) : { path: args.path, commit: S2 }; }, bus: { instances: async () => { calls.instances++; return instances ? instances() : structuredClone(INSTANCES); } }, }; } async function setup(t, { enabled = true, file = true, bus = true, ...fakes } = {}) { const lines = [], fake = transports(fakes); const auth = await createSsoSession(t, { host: 'console.example' }); const server = await startServer({ port: 0, sso: auth.sso, log: l => lines.push(l), send: enabled ? async () => ({}) : null, projectFile: file ? fake.projectFile : null, projectSave: enabled ? fake.projectSave : null, bus: bus ? fake.bus : null, }); t.after(() => close(server)); const session = await auth.signIn(server.address().port); return { calls: fake.calls, lines, port: server.address().port, headers: auth.writeHeaders(session), read: auth.readHeaders(session) }; } function call(s, { method = 'POST', path = '/api/north-star/approve', hs = s.headers, data = payload } = {}) { return new Promise((resolve, reject) => { const req = request({ hostname: '127.0.0.1', port: s.port, path, method, headers: hs }, r => { let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, type: r.headers['content-type'], text, body: text.startsWith('{') ? JSON.parse(text) : null })); }); req.on('error', reject); req.end(data === null ? undefined : typeof data === 'string' ? data : JSON.stringify(data)); }); } const get = (s, path) => call(s, { method: 'GET', path, hs: s.read, data: null }); test('the interview read gives the first message, the questions, the drafts and the first assistant instance', async t => { const s = await setup(t); const r = await get(s, '/api/onboarding/interview?project=stack'); assert.equal(r.status, 200, r.text); assert.equal(r.body.message, interviewMessage(TEMPLATE, { project: 'stack' })); assert.equal(r.body.threadName, 'Onboarding interview'); assert.deepEqual(r.body.questions, TEMPLATE.questions); assert.deepEqual(r.body.drafts, TEMPLATE.drafts); assert.equal(r.body.northStarTemplate, northStarTemplateText()); // The first assistant by instance key, named by its persona when it has one. assert.deepEqual(r.body.assistant, { instance: 'assistant', name: 'Ada' }); assert.equal(s.calls.instances, 1); assert.deepEqual(s.calls.file, [], 'the read asks the bus for nothing but the instances'); }); test('the interview read takes one project id, answers 405 to a write and reports a missing or failing bus', async t => { const s = await setup(t); for (const path of ['/api/onboarding/interview', '/api/onboarding/interview?project=a&project=b', '/api/onboarding/interview?project=stack&x=1', '/api/onboarding/interview?project=..%2Fx', '/api/onboarding/interview?project=%20x', '/api/onboarding/interview?project=']) assert.equal((await get(s, path)).status, 400, path); assert.equal(s.calls.instances, 0); assert.equal((await call(s, { path: '/api/onboarding/interview?project=stack', data: {} })).status, 405); const none = await setup(t, { bus: false }); const missing = await get(none, '/api/onboarding/interview?project=stack'); assert.equal(missing.status, 503); assert.equal(missing.body.error, 'not-configured'); const only = await setup(t, { instances: () => ({ ...INSTANCES, instances: { coder: { definition: 'coder' } } }) }); const noAssistant = await get(only, '/api/onboarding/interview?project=stack'); assert.equal(noAssistant.status, 200); assert.equal(noAssistant.body.assistant, null); const plain = await setup(t, { instances: () => ({ ...INSTANCES, instances: { b: { definition: 'assistant' } } }) }); assert.deepEqual((await get(plain, '/api/onboarding/interview?project=stack')).body.assistant, { instance: 'b', name: 'b' }); for (const [instances, status, error] of [ [() => { throw new BusReadError('human-required'); }, 403, 'human-required'], [() => { throw new BusReadError('outcome-unknown'); }, 503, 'outcome-unknown'], [() => ({ instances: [] }), 502, 'invalid-response'], [() => ({ instances: { a: { definition: 'assistant', vars: { 'persona.name': 5 } } } }), 502, 'invalid-response'], ]) { const bad = await setup(t, { instances }); const r = await get(bad, '/api/onboarding/interview?project=stack'); assert.equal(r.status, status, error); assert.equal(r.body.error, error); } }); test('an approval copies the exact reviewed draft into the spec through one human save', async t => { const s = await setup(t), r = await call(s); assert.equal(r.status, 200, r.text); assert.equal(r.body.path, SPEC_PATH); assert.equal(r.body.commit, S2); assert.deepEqual(r.body.draft, { path: DRAFT, commit: D1 }); assert.deepEqual(s.calls.file, [{ project: 'stack', path: DRAFT }]); assert.deepEqual(s.calls.save, [{ project: 'stack', path: SPEC_PATH, base: S1, content: TEXT }]); assert.ok(s.lines.some(l => l.includes('north-star-approve') && l.includes(`commit=${S2}`) && l.includes('project="stack"') && l.includes(`draft="${D1}"`)), s.lines.join('\n')); assert.equal(s.lines.some(l => l.includes('Onboard new organizations')), false, 'the content is never logged'); }); test('an approval needs send, the save and read verbs, the named Host, a session, the exact Origin and same-origin Fetch Metadata', async t => { for (const options of [{ enabled: false }, { file: false }]) { const off = await setup(t, options), refused = await call(off); assert.equal(refused.status, 403, JSON.stringify(options)); assert.equal(refused.body.sent, false); assert.deepEqual(off.calls.file, []); assert.deepEqual(off.calls.save, []); } const s = await setup(t); for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['origin', 'https://else.example', 403], ['sec-fetch-site', 'cross-site', 403], ['origin', undefined, 403], ['sec-fetch-site', undefined, 403], ['cookie', undefined, 401]]) { const hs = { ...s.headers }; if (value === undefined) delete hs[key]; else hs[key] = value; const r = await call(s, { hs }); assert.equal(r.status, status, `${key}=${value}`); assert.notEqual(r.body?.sent, true); } assert.equal((await call(s, { method: 'GET', hs: s.read, data: null })).status, 405); assert.deepEqual(s.calls.file, []); assert.deepEqual(s.calls.save, []); }); test('an approval validates JSON, strict keys, text values, the project id and both commits before any read', async t => { const s = await setup(t); for (const data of [{ ...payload, extra: 1 }, { project: 'stack', draft: D1 }, { ...payload, base: 5 }, { ...payload, draft: null }, [], 'null', '5', 'bad', '{', { ...payload, project: '../x' }, { ...payload, draft: 'abc' }, { ...payload, base: S1.toUpperCase() }]) { const r = await call(s, { data }); assert.equal(r.status, 400, JSON.stringify(data)); assert.equal(r.body.sent, false); } assert.equal((await call(s, { hs: { ...s.headers, 'content-type': 'text/plain' } })).status, 400); assert.deepEqual(s.calls.file, []); assert.deepEqual(s.calls.save, []); // A crafted value is quoted in the log, so it can't start a line of its own. await call(s, { data: { ...payload, project: 'x\nforged' } }); assert.ok(s.lines.length > 0); assert.equal(s.lines.some(l => l.includes('\n')), false, s.lines.join('|')); }); test('a draft that changed, is empty or cannot be read is not approved and nothing is saved', async t => { const draftFile = over => args => ({ path: args.path, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T00:00:00Z', ...over }); for (const [read, status, error] of [ [draftFile({ commit: S2 }), 409, 'draft-stale'], [draftFile({ content: ' \n' }), 409, 'draft-empty'], [() => { throw new BusReadError('project-read-missing'); }, 404, 'project-read-missing'], [() => { throw new BusReadError('human-required'); }, 403, 'human-required'], [draftFile({ commit: undefined }), 502, 'invalid-response'], [() => { throw new Error('socket closed'); }, 502, 'invalid-response'], ]) { const s = await setup(t, { read }), r = await call(s); assert.equal(r.status, status, error); assert.equal(r.body.error, error); assert.equal(r.body.sent, false); assert.match(r.body.message, /^not approved: /); assert.deepEqual(s.calls.save, [], error); } }); test('a save refusal keeps its status; a lost or unusable answer is reported unknown and never retried', async t => { for (const [code, status] of [['stale-base', 409], ['human-required', 403], ['project-busy', 409], ['project-not-configured', 503]]) { const s = await setup(t, { save: () => { throw new BusReadError(code); } }), r = await call(s); assert.equal(r.status, status, code); assert.equal(r.body.error, code); assert.equal(r.body.sent, false); if (code === 'stale-base') assert.equal(r.body.message, 'not approved: the north star changed since you opened it. Reload, then review again.'); else assert.equal(r.body.message, `not approved: the bus refused it (${code}).`); assert.equal(s.calls.save.length, 1); } for (const save of [() => { throw new BusReadError('outcome-unknown'); }, () => { throw new Error('socket closed'); }, () => ({ path: SPEC_PATH }), () => ({ path: 'docs/x.md', commit: S2 }), () => ({ path: SPEC_PATH, commit: 'nope' })]) { const s = await setup(t, { save }), r = await call(s); assert.equal(r.status, 503); assert.equal(r.body.error, 'outcome-unknown'); assert.equal(r.body.sent, 'unknown'); assert.match(r.body.message, /may have been saved; nothing was retried/); assert.equal(s.calls.save.length, 1); } }); test('config offers the approval only where the route can pass; granted users and the tester never reach it', async t => { const config = async options => (await get(await setup(t, options), '/api/config')).body; assert.equal((await config({})).northStarApprove, true); assert.equal((await config({ enabled: false })).northStarApprove, false); assert.equal((await config({ file: false })).northStarApprove, false); // A granted user stops at the owner gate before either route. assert.equal(userRoute('/api/north-star/approve', 'POST'), false); assert.equal(userRoute('/api/onboarding/interview', 'GET'), false); // The tester listener refuses projectSave at start, so its approval has no save to reach. const tester = await testerFixture(t); const status = await new Promise((resolve, reject) => { const req = request({ socketPath: tester.web.address(), path: '/api/north-star/approve', method: 'POST', headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => { r.resume(); r.on('end', () => resolve(r.statusCode)); }); req.on('error', reject); req.end(JSON.stringify(payload)); }); assert.equal(status, 403); assert.deepEqual(tester.calls, []); }); test('in an organization the approval and the interview read reach only the selected organization; the plain save stays fenced', async t => { const fakes = { alpha: transports(), beta: transports() }; const organizations = { list: async () => [], resolve: async id => { if (!(id in fakes)) return null; const { projectFile, projectSave, bus } = fakes[id]; return { busHealthy: id === 'alpha', send: async () => ({}), projectFile, projectSave, bus }; }, }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server)); const base = `http://127.0.0.1:${server.address().port}`; const headers = { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }; const post = async (path, body, extra = {}) => { const r = await fetch(base + path, { method: 'POST', headers: { ...headers, ...extra }, body: JSON.stringify(body) }); return [r.status, await r.json()]; }; const get = async path => { const r = await fetch(base + path); return [r.status, await r.json()]; }; const [status, body] = await post('/api/orgs/alpha/north-star/approve', payload); assert.equal(status, 200, JSON.stringify(body)); assert.equal(body.commit, S2); assert.equal(fakes.alpha.calls.save.length, 1); assert.equal(fakes.beta.calls.save.length, 0); assert.equal((await post('/api/orgs/alpha/north-star/approve', payload, { origin: 'http://evil.example' }))[0], 403); assert.deepEqual(await post('/api/north-star/approve', payload), [404, { error: 'org-unavailable' }]); assert.deepEqual(await post('/api/orgs/missing/north-star/approve', payload), [404, { error: 'org-unavailable' }]); assert.deepEqual(await post('/api/orgs/alpha/project-file', { project: 'stack', path: SPEC_PATH, base: S1, content: '# x\n' }), [403, { error: 'unavailable', sent: false }]); assert.equal(fakes.alpha.calls.save.length, 1); const alpha = (await get('/api/orgs/alpha/config'))[1]; assert.equal(alpha.northStarApprove, true); assert.equal(alpha.edit, false); assert.equal((await get('/api/orgs/beta/config'))[1].northStarApprove, false, 'an offline organization does not offer the approval'); const [read, interview] = await get('/api/orgs/alpha/onboarding/interview?project=stack'); assert.equal(read, 200); assert.equal(interview.assistant.instance, 'assistant'); assert.equal(fakes.alpha.calls.instances, 1); assert.equal(fakes.beta.calls.instances, 0); }); test('a file read keeps the read time in at and adds the last commit time as committed; a list read does not', async t => { const s = await setup(t); const before = Date.now(); const spec = await get(s, `/api/project-file?project=stack&path=${encodeURIComponent(SPEC_PATH)}`); const draft = await get(s, `/api/project-file?project=stack&path=${encodeURIComponent(DRAFT)}`); assert.equal(spec.status, 200, spec.text); assert.equal(draft.status, 200, draft.text); assert.equal(spec.body.committed, '2026-11-08T00:00:00.000Z'); assert.equal(draft.body.committed, '2026-11-09T00:00:00.000Z'); for (const r of [spec, draft]) assert.ok(Date.parse(r.body.at) >= before - 1000, 'at stays the read time'); const odd = await setup(t, { read: args => ({ path: args.path, head: D1, commit: S1, content: 'x', author: 'jason', at: 'not a time' }) }); const plain = await get(odd, `/api/project-file?project=stack&path=${encodeURIComponent(SPEC_PATH)}`); assert.equal(plain.status, 200); assert.equal('committed' in plain.body, false, 'an unreadable commit time is left out'); }); test('the page loads the onboarding script and stylesheet', async t => { const s = await setup(t); const js = await get(s, '/onboarding.js'), css = await get(s, '/onboarding.css'), page = await get(s, '/'); assert.equal(js.status, 200); assert.match(js.type, /^text\/javascript/); assert.match(js.text, /MosaicOnboarding/); assert.equal(css.status, 200); assert.match(css.type, /^text\/css/); assert.match(css.text, /\.onboarding-panel/); assert.equal(page.status, 200); assert.match(page.text, /