feat(webui): onboarding interview after Quick Setup and on the project page (F2)

- Quick Setup's done state offers Start onboarding interview next to Open
  organization. It opens a conversation with the assistant and sends the
  template's first message; Open organization skips it.
- The project overview offers the interview while the north star is the
  init template, then shows the assistant's draft with Approve as north
  star and the equivalent CLI command.
- GET /api/onboarding/interview serves the template and the assistant
  instance. POST /api/north-star/approve runs approveNorthStarDraft with
  the save's boundary (header user, exact Origin, same-origin Fetch
  Metadata, strict JSON). In an organization it is the one project write
  the fence lets through; the tester and granted users never reach it.
  /api/config says northStarApprove where the route can pass.
- GET /api/project-file adds `committed`, the file's last commit time; `at`
  stays the read time.
This commit is contained in:
jason.woltje committed 2026-11-09 12:58:32 -06:00
1 parent 00ae2c718c
commit 7fe301a172
10 files changed
+711 -12

No files matched your search

+2
View File
@@ -24,6 +24,8 @@ baseline CLI.
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
| Send a thread message | `mosaic talk --thread <thread> <text> --org <id>` | Planned enabled |
| Start the onboarding interview (F2) | `mosaic thread create <chat-project> "Onboarding interview" --instance <assistant> --org <id>`, then `mosaic talk --thread <thread> "$(mosaic project interview <project>)" --org <id>` | Enabled through the thread create and send routes above |
| Approve the north-star draft (F2) | `mosaic project north-star <project> --org <id> --approve <draft-commit> --base <spec-commit>` | Enabled: copies the reviewed draft into `spec/north-star.md` through the human save (`project.file.save`) |
| Archive or unarchive a chat project | `mosaic project archive` / `mosaic project unarchive` | Unavailable pending parity |
| Open a room or send a room message | No CLI equivalent identified | Unavailable |
| Share or unshare a thread | No CLI equivalent identified | Unavailable |
+1
View File
@@ -6,6 +6,7 @@
<link rel="stylesheet" href="/project-archive.css"><link rel="stylesheet" href="/projects.css">
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script>
<script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
<body>
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
+12
View File
@@ -0,0 +1,12 @@
/* F2: the onboarding interview offer and the north-star draft on the project page. */
.onboarding-panel { max-width:100%; }
.onboarding-panel p { max-width:72ch; }
.onboarding-questions { margin:8px 0 12px; padding-left:22px; }
.onboarding-questions li { margin:4px 0; }
.onboarding-actions { display:flex; flex-wrap:wrap; gap:8px; margin:12px 0 4px; }
.onboarding-actions .btn { display:inline-flex; align-items:center; min-height:44px; text-decoration:none; }
.onboarding-draft { max-height:320px; overflow:auto; border:1px solid var(--line); border-radius:8px; padding:12px 16px; margin:8px 0; overflow-wrap:anywhere; }
.onboarding-draft:focus-visible { outline:2px solid var(--action, currentColor); outline-offset:2px; }
.onboarding-verb, .onboarding-status { color:var(--muted); font-size:13px; overflow-wrap:anywhere; }
.onboarding-status:empty { display:none; }
@media (max-width:699px) { .onboarding-actions .btn { width:100%; justify-content:center; } }
+133
View File
@@ -0,0 +1,133 @@
/* F2 (REQ-SETUP-2): the onboarding interview after Quick Setup, and the north-star draft on the project page.
The page holds no authority of its own. The interview opens through the chat routes the Projects page
already uses; the assistant writes its draft with its own record grant; Approve posts the draft and spec
commits the user saw to /api/north-star/approve, which saves through the human save verb. */
(() => {
'use strict';
const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/, UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, COMMIT = /^[0-9a-f]{40}$/;
const esc = v => String(v ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const normal = text => String(text ?? '').replace(/\r\n?/g, '\n').trim();
// An explicit organization is addressed directly, so a page shown inside another organization still
// reaches the one Quick Setup just made. Without one, display.js scopes /api/ to the current organization.
const base = org => org ? `/api/orgs/${encodeURIComponent(org)}/` : '/api/';
async function call(url, body) {
const init = body === undefined ? { cache: 'no-store', redirect: 'manual' }
: { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), redirect: 'manual' };
let response;
try { response = await fetch(url, init); } catch { return { status: 0, body: { error: 'network', sent: body === undefined ? undefined : 'unknown' } }; }
const value = await response.json().catch(() => null);
return { status: response.status, body: value && typeof value === 'object' ? value : { error: 'unreadable', ...(body === undefined ? {} : { sent: 'unknown' }) } };
}
const interviews = new Map();
function interview(org, repo) {
const key = `${org || ''}\0${repo}`;
if (!interviews.has(key)) interviews.set(key, call(`${base(org)}onboarding/interview?project=${encodeURIComponent(repo)}`).then(r => {
if (r.status !== 200) { interviews.delete(key); throw Object.assign(Error(r.body.message || `The interview could not be read (${r.body.error || r.status}).`), { code: r.body.error }); }
return r.body;
}));
return interviews.get(key);
}
const refusal = (r, what) => r.body.sent === 'unknown' || r.status === 0
? `${what}: the outcome is unknown. Check Conversations before you try again.`
: `${what} was refused (${r.body.message || r.body.error || r.status}).`;
// Opens a conversation with the assistant in the chat project and sends the interview's first message.
// `chatProject` is the chat project id, `repo` the records project the drafts go to.
async function start({ org = null, chatProject, repo, status = () => {} }) {
if (!UUID.test(chatProject || '') || !ID.test(repo || '')) { status('This project cannot start an interview.'); return null; }
let plan;
try { plan = await interview(org, repo); } catch (e) { status(e.message); return null; }
if (!plan.assistant || typeof plan.assistant.instance !== 'string') { status('No assistant is configured for this organization, so the interview cannot start.'); return null; }
status('Opening the interview conversation…');
let created;
// A second interview in one project gets a numbered name; the first one stays where it was.
for (let n = 1; n <= 9; n++) {
created = await call(`${base(org)}chat/threads`, { project: chatProject, name: n === 1 ? plan.threadName : `${plan.threadName} ${n}`, instance: plan.assistant.instance });
if (created.status !== 409 || created.body.error !== 'thread-exists') break;
}
if (created.status !== 200 && created.status !== 201) { status(refusal(created, 'Opening the conversation')); return null; }
if (!UUID.test(created.body.id || '')) { status('Opening the conversation: the reply could not be read, so its outcome is unknown. Check Conversations before you try again.'); return null; }
const thread = created.body.id;
const sent = await call(`${base(org)}chat/threads/${thread}/messages`, { body: plan.message });
const ok = (sent.status === 200 || sent.status === 201) && UUID.test(sent.body.id || '');
go(org, thread);
if (!ok) status(`${refusal(sent, 'The first interview message')} The conversation is open; send the interview message from there.`);
else status(`Interview started with ${plan.assistant.name}.`);
return { thread, sent: ok };
}
function go(org, thread) {
const current = window.MosaicOrg?.id ?? null;
if (!org || org === current) { location.hash = `#/projects/threads/${thread}`; return; }
history.replaceState(null, '', `#/orgs/${encodeURIComponent(org)}/projects/threads/${thread}`);
location.reload();
}
// The project page's panel. It offers the interview while the north star is still the template
// project init wrote, and shows the assistant's draft with Approve when the draft is newer than the
// north star and says something else.
const runs = new WeakMap(), notes = new Map(), drafts = new Map();
let config = null;
async function offer(container, { project, section, north, docs, threads = [], canSend = false }) {
const run = (runs.get(container) || 0) + 1; runs.set(container, run);
const slot = () => container.parentElement?.querySelector(':scope > [data-onboarding]');
const clear = () => slot()?.remove();
if (!['overview', 'requirements'].includes(section) || project?.archived || !ID.test(project?.repo || '') || typeof north?.content !== 'string' || !COMMIT.test(north.commit || '')) return clear();
let plan;
try { plan = await interview(null, project.repo); } catch { return clear(); }
const listed = Array.isArray(docs?.files) && docs.files.some(f => f?.path === plan.drafts.northStar);
let draft = null;
if (listed) {
const key = `${project.repo}\0${north.commit}`;
const r = await call(`/api/project-file?project=${encodeURIComponent(project.repo)}&path=${encodeURIComponent(plan.drafts.northStar)}`);
if (r.status === 200 && typeof r.body.content === 'string' && COMMIT.test(r.body.commit || '')) { draft = r.body; drafts.set(key, draft); }
else draft = drafts.get(key) ?? null;
}
if (!config) config = await call('/api/config').then(r => r.status === 200 ? r.body : null);
if (runs.get(container) !== run || !container.isConnected) return;
const template = normal(north.content) === normal(plan.northStarTemplate);
// `committed` is each file's last commit time; `at` on a Console read is only when it was read.
const pending = draft && draft.content.trim() && normal(draft.content) !== normal(north.content) && Date.parse(draft.committed) >= Date.parse(north.committed);
const note = notes.get(project.repo);
if (!template && !pending && !note) return clear();
const existing = threads.filter(t => !t.shared && t.project === project.id && (t.name === plan.threadName || t.name.startsWith(plan.threadName + ' ')));
const approve = config?.northStarApprove === true;
const verb = `mosaic project north-star ${project.repo}${window.MosaicOrg ? ` --business ${window.MosaicOrg.id}` : ''}`;
const signature = JSON.stringify([template, pending ? [draft.commit, north.commit] : null, existing.map(t => t.id), approve, canSend, notes.get(project.repo)]);
let panel = slot();
if (panel?.dataset.signature === signature) return;
if (!panel) { panel = document.createElement('section'); panel.dataset.onboarding = ''; panel.className = 'project-panel onboarding-panel'; panel.setAttribute('aria-labelledby', 'onboarding-title'); container.before(panel); }
panel.dataset.signature = signature;
// After an approval the spec holds the draft, so neither offer applies; the receipt stays until reload.
if (!template && !pending) { panel.innerHTML = `<h2 id="onboarding-title">North star approved</h2><p class="onboarding-status" role="status">${esc(note)}</p>`; return; }
panel.innerHTML = `<h2 id="onboarding-title">${pending ? 'North star draft ready' : 'Onboarding interview'}</h2>`
+ (pending
? `<p>The assistant drafted a north star in <code>${esc(plan.drafts.northStar)}</code> (commit <code>${esc(draft.commit.slice(0, 12))}</code>). Read it below. Approving copies this exact draft into <code>spec/north-star.md</code> as your commit.</p><div class="onboarding-draft" data-onboarding-draft tabindex="0" aria-label="North star draft"></div>`
+ (approve ? `<div class="onboarding-actions"><button type="button" class="btn primary" data-onboarding-approve>Approve as north star</button></div>` : '<p>Approving is not available in this view.</p>')
+ `<p class="onboarding-verb">Same as <code>${esc(verb)} --approve ${esc(draft.commit)} --base ${esc(north.commit)}</code></p>`
: `<p>The north star is still the template. The assistant can interview you, one question at a time, and draft the north star and a first PRD for you to approve.</p>`
+ `<ol class="onboarding-questions">${plan.questions.map(q => `<li>${esc(q.text)}</li>`).join('')}</ol>`)
+ `<div class="onboarding-actions">${existing.map(t => `<a class="btn" href="#/projects/threads/${esc(t.id)}">Open ${esc(t.name)}</a>`).join('')}`
+ (!pending && canSend && plan.assistant ? `<button type="button" class="btn${existing.length ? '' : ' primary'}" data-onboarding-start>${existing.length ? 'Start a new interview' : 'Start onboarding interview'}</button>` : '')
+ `</div>${!pending && !plan.assistant ? '<p>No assistant is configured, so the interview cannot start.</p>' : ''}<p class="onboarding-status" role="status">${esc(note || '')}</p>`;
const view = panel.querySelector('[data-onboarding-draft]');
if (view) view.replaceChildren(window.MosaicMarkdown?.render(draft.content) || document.createTextNode(draft.content));
const status = text => { const el = panel.querySelector('.onboarding-status'); if (el) el.textContent = text; };
panel.querySelector('[data-onboarding-start]')?.addEventListener('click', async e => {
e.currentTarget.disabled = true;
const done = await start({ chatProject: project.id, repo: project.repo, status });
if (!done) e.currentTarget.disabled = false;
});
panel.querySelector('[data-onboarding-approve]')?.addEventListener('click', async e => {
const button = e.currentTarget; button.disabled = true; status('Approving…');
const r = await call('/api/north-star/approve', { project: project.repo, draft: draft.commit, base: north.commit });
if (r.status === 200 && COMMIT.test(r.body.commit || '')) {
notes.set(project.repo, `Approved. spec/north-star.md is now commit ${r.body.commit.slice(0, 12)}. The product manager and CTO get the spec notice.`);
status(notes.get(project.repo)); button.remove();
return;
}
status(r.body.sent === 'unknown' || r.status === 0 ? 'The approval may have been saved. Reload the project before you approve again.' : (r.body.message || `Not approved (${r.body.error || r.status}).`));
if (r.body.sent === false) button.disabled = false;
});
}
window.MosaicOnboarding = Object.freeze({ start, offer });
})();
+7 -4
View File
@@ -38,7 +38,7 @@
<label class="org-quick-risk"><input id="org-risk-accept" type="checkbox"> I understand local-only repositories have no copy elsewhere and can be lost.</label>
<div class="settings-actions"><button type="button" id="org-quick-run">Create and set up organization</button></div>
<ol id="org-quick-steps" class="org-steps" aria-live="polite"></ol>
<div class="settings-actions"><button type="button" id="org-quick-open-org" hidden>Open organization</button></div>
<div class="settings-actions"><button type="button" id="org-quick-interview" hidden>Start onboarding interview</button><button type="button" class="org-secondary" id="org-quick-open-org" hidden>Open organization</button></div>
</section>
<section id="org-editor" class="org-editor" hidden aria-labelledby="org-editor-title">
<div class="org-editor-head"><h3 id="org-editor-title">New organization</h3><p id="org-editor-verb" class="org-verb"></p></div>
@@ -101,7 +101,7 @@
function mount() {
const root = $('org-admin'); if (!root) return () => {};
let alive = true, canWrite = false, editorDirty = false, presets = [];
let alive = true, canWrite = false, editorDirty = false, presets = [], quickDone = null;
const setupDirty = new Set();
window.MosaicOrgCreate.hasDraft = () => editorDirty || setupDirty.size > 0 || !!$('org-new-id')?.value.trim();
// mode: 'create' | 'edit'; doc: the one canonical (browser) document.
@@ -272,7 +272,7 @@
$('org-preset').value = presets.find(p => p.available.ok)?.name || '';
updatePreset();
$('org-paths').hidden = false; $('org-quick').hidden = true; $('org-editor').hidden = true; $('org-receipt').hidden = true; $('org-setup').hidden = true; $('org-delete').hidden = true; $('org-delete-success').hidden = true; $('org-archive').hidden = true; $('org-archived').hidden = true; resetArchive();
$('org-quick-steps').innerHTML = ''; $('org-quick-open-org').hidden = true; banner(''); setQuery(null);
$('org-quick-steps').innerHTML = ''; $('org-quick-open-org').hidden = true; $('org-quick-interview').hidden = true; quickDone = null; banner(''); setQuery(null);
$('org-preset').focus();
}
function updatePreset() {
@@ -335,10 +335,11 @@
if (result.status !== 200 && result.status !== 201) throw Error(result.body.message || result.body.errors?.map(e => e.message).join(' ') || `Refused (${result.body.error || result.status}).`);
// A success status alone is not proof: an unreadable or unexpected reply leaves the outcome unknown, so stop here.
if (step === 'chat-project' ? !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(result.body.id || '') : result.body.outcome !== 'done' || result.body.step !== step) throw Error('The reply could not be read, so this step\'s outcome is unknown.');
if (step === 'chat-project') quickDone = { org: id, chatProject: result.body.id, repo: project };
card.querySelector('.org-pill').textContent = 'Done'; card.classList.add('org-step-done');
if (result.body.verb) card.querySelector('code').textContent = result.body.verb;
}
editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; status('Organization setup complete.');
editorDirty = false; $('org-new-id').value = ''; $('org-quick-open-org').hidden = false; $('org-quick-interview').hidden = !window.MosaicOnboarding; status('Organization setup complete.');
await refreshList();
} catch (e) {
const card = list.children[Math.min(current, steps.length - 1)];
@@ -603,6 +604,8 @@
[$('org-quick-open'), 'click', openQuick],
[$('org-advanced-open'), 'click', openAdvanced],
[$('org-quick-run'), 'click', quickRun],
// F2: the interview opens a conversation with the assistant; Open organization skips it.
[$('org-quick-interview'), 'click', async () => { const b = $('org-quick-interview'); b.disabled = true; if (!await window.MosaicOnboarding?.start({ ...quickDone, status })) b.disabled = false; }],
[$('org-quick-open-org'), 'click', () => { history.replaceState(null, '', `#/orgs/${encodeURIComponent(st.id)}/projects`); location.reload(); }],
[$('org-open-form'), 'submit', e => { e.preventDefault(); const id = $('org-open-id').value; if (id) openExisting(id); }],
[$('org-form'), 'submit', save],
+1
View File
@@ -265,6 +265,7 @@
// Retained controls (the request selector) draw from the newest accepted read, even when
// this read changes nothing visible and the DOM is kept.
contentModels.set(container,{data,project,section,canSend,roomReceipt});
window.MosaicOnboarding?.offer(container,{project,section,north:data.north,docs:data.docs,threads:data.threads,canSend}); // F2: its panel sits before the container, outside draw().
if (contentPaints.get(container) === signature) return;
contentPaints.set(container, signature);
// Capture at commit time: the user may have interacted while the read was pending.
+88 -6
View File
@@ -9,7 +9,8 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from
// Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched.
const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code);
import { ReadError, readStatus, fieldTable } from './reads.mjs';
import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { THREAD_DEFINITION, THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs';
import { OnboardingError, approveNorthStarDraft, interviewMessage, loadInterviewTemplate, northStarTemplateText } from '../../project/src/onboarding.mjs';
import { chatHTTP } from './chat-http.mjs';
import { projectArchiveHTTP } from './project-archive-http.mjs';
import { processingHTTP } from './processing-http.mjs';
@@ -54,12 +55,12 @@ const files = new Map([
['/', ['index.html', 'text/html; charset=utf-8']],
['/proxy.js', ['proxy.js', 'text/javascript; charset=utf-8']],
['/project-remote.js', ['project-remote.js', 'text/javascript; charset=utf-8']],
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
...['app.js', 'brand.js', 'bus.js', 'markdown-preview.js', 'chat.js', 'project-archive.js', 'processing.js', 'agents.js', 'federation.js', 'requests.js', 'org-create.js', 'onboarding.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
['/files.js', ['files.js', 'text/javascript; charset=utf-8']],
['/projects.js', ['projects.js', 'text/javascript; charset=utf-8']],
['/projects.css', ['projects.css', 'text/css; charset=utf-8']],
['/files.css', ['files.css', 'text/css; charset=utf-8']],
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css', 'chat.css', 'project-archive.css', 'processing.css', 'org-create.css', 'onboarding.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
['/display.js', ['display.js', 'text/javascript; charset=utf-8']],
['/inbox.js', ['inbox.js', 'text/javascript; charset=utf-8']],
['/inbox.css', ['inbox.css', 'text/css; charset=utf-8']],
@@ -638,12 +639,82 @@ async function projectFileRead(res, read, path, search) {
? typeof result?.content === 'string' && COMMIT.test(result.commit) && COMMIT.test(result.head) && typeof result.path === 'string'
: COMMIT.test(result?.head) && Array.isArray(result.files) && result.files.every(f => typeof f?.path === 'string');
if (!valid) throw new BusReadError('invalid-response');
return json(res, 200, { ...result, at: new Date().toISOString() });
// `at` is the read time, as on every Console read. F2: a file read also says when the file's last
// commit was made (`committed`), so the onboarding panel can tell a draft newer than the north star.
const committed = file && typeof result.at === 'string' && Number.isFinite(Date.parse(result.at)) ? { committed: new Date(result.at).toISOString() } : {};
return json(res, 200, { ...result, ...committed, at: new Date().toISOString() });
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// F2 (REQ-SETUP-2): the onboarding interview after Quick Setup. The read gives the page the first
// message, the questions, the draft paths and the assistant instance the conversation opens with. The
// approval has the save's boundary (header user, exact Origin, same-origin Fetch Metadata, JSON, strict
// keys) around approveNorthStarDraft, which reads the draft and writes only through the human save verb.
const RECORD_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const APPROVE_KEYS = ['project', 'draft', 'base'];
async function onboardingRead(res, { bus, search }) {
const query = new URLSearchParams(search);
if ([...query.keys()].some(k => k !== 'project') || query.getAll('project').length !== 1 || !RECORD_ID.test(query.get('project')))
return json(res, 400, { error: 'invalid-request', message: 'an interview read takes a project' });
let template, message;
try { template = loadInterviewTemplate(); message = interviewMessage(template, { project: query.get('project') }); }
catch (err) { return json(res, 503, { error: 'onboarding-template-invalid', message: err.message }); }
if (!bus?.instances) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
let assistant = null;
try {
const value = await bus.instances();
if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances)) throw new BusReadError('invalid-response');
// The first assistant by instance key; a choice between several is a follow-up.
const key = Object.keys(value.instances).sort().find(k => value.instances[k]?.definition === THREAD_DEFINITION);
if (key !== undefined) {
const name = value.instances[key].vars?.['persona.name'] || key;
if (typeof name !== 'string') throw new BusReadError('invalid-response');
assistant = { instance: key, name };
}
} catch (err) {
const code = err instanceof BusReadError ? err.code : 'invalid-response';
return json(res, busStatus(code), { error: code });
}
return json(res, 200, { id: template.id, threadName: template.threadName, message, questions: template.questions,
drafts: template.drafts, northStarTemplate: northStarTemplateText(), assistant, at: new Date().toISOString() });
}
async function northStarApprove(req, res, { projectFile, projectSave, preview, log }) {
let target = null;
// Values are quoted and cut short so a crafted one can't forge a log line. No content is logged.
const line = outcome => log(`${new Date().toISOString()} north-star-approve user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} draft=${quoted(target?.draft)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'an approval needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'an approval needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== APPROVE_KEYS.length || !APPROVE_KEYS.every(k => typeof value[k] === 'string'))
return refuse(400, 'invalid-request', 'an approval takes project, draft and base, all text, nothing else');
target = value;
let template;
try { template = loadInterviewTemplate(); } catch (err) { return refuse(503, 'onboarding-template-invalid', err.message); }
let result;
try {
result = await approveNorthStarDraft({ read: projectFile, save: projectSave, project: value.project, draft: value.draft, base: value.base, drafts: template.drafts });
} catch (err) {
if (!(err instanceof OnboardingError) || (err.stage === 'save' && !err.cause))
return refuse(503, 'outcome-unknown', 'the approval may have been saved; nothing was retried. Reload the project before approving again', 'unknown');
if (err.stage === 'check') return refuse(400, err.code, err.message);
if (err.stage === 'read') {
if (['draft-stale', 'draft-empty'].includes(err.code)) return refuse(409, err.code, `not approved: ${err.message}`);
const code = err.cause instanceof BusReadError ? err.cause.code : 'invalid-response';
return refuse(busStatus(code), code, `not approved: the draft could not be read (${code}).`);
}
const { code, sent } = sendOutcome(err.cause);
return sent === false
? refuse(busStatus(code), code, code === 'stale-base' ? 'not approved: the north star changed since you opened it. Reload, then review again.' : `not approved: the bus refused it (${code}).`)
: refuse(503, 'outcome-unknown', 'the approval may have been saved; nothing was retried. Reload the project before approving again', 'unknown');
}
line(`commit=${result.commit}`);
return json(res, 200, { ...result, at: new Date().toISOString() });
}
// Row 225 (C2-10b, design 7.4): the chat's library status line and a write notice's Revert. The status
// is a read like the file list. The revert is her write (C2-02's `library.revert`), with the save's
// boundary: header user, exact Origin, same-origin Fetch Metadata, JSON, strict keys. Until the verb
@@ -871,7 +942,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, upstream = null } = selected);
}
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
if (!hook && !['GET', 'HEAD'].includes(req.method) && !decisionPath && path !== '/api/north-star/approve' && !(/^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f-]+\/(?:messages|open))$/i.test(path)))
return json(res, 403, { error: 'unavailable', sent: false });
}
if (hook) {
@@ -964,6 +1035,17 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
return projectFileSend(req, res, { projectSave, preview: signer, log });
}
// F2: in an organization this is the one project write past the fence above; the human save verb
// still decides the author, the base and the path. A granted user and the tester never reach it.
if (path === '/api/north-star/approve') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
if (tester || granted || !send || !projectSave || !projectFile || !named) return json(res, 403, { error: 'read-only', sent: false });
return northStarApprove(req, res, { projectFile, projectSave, preview: signer, log });
}
if (path === '/api/onboarding/interview') {
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
return onboardingRead(res, { bus, search });
}
// Message sending has its own bounded payload and repeats the identity check.
if (send && path === '/api/bus/message') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -1056,7 +1138,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy } : {}), decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
+2 -2
View File
@@ -18,8 +18,8 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.querySelector("#session-count").textContent'), '3 of 4');
await b.evaluate('document.querySelector("#hide-offline").click()');
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js.
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/bus.js', '/requests.js']);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
assert.equal(await b.evaluate('!!window.injected'), false);
@@ -0,0 +1,207 @@
// F2 (PRD 1.20 draft REQ-SETUP-2): the onboarding interview in the browser. Quick Setup runs for real
// (org service, registry and repositories in a scratch directory) with the Test model preset; the
// organization's chat and bus are fakes that record what the page asked for. The project-page offer
// runs the real interview read, file reads, chat writes and approval route behind the shared Projects stub.
// MOSAIC_UX_SHOTS=<dir> also saves 1440x900 and 390x844 screenshots of each state.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import * as service from '../../business/src/org-service.mjs';
import * as registry from '../../business/src/org-registry.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
import { P, stub } from './helpers/projects-review-fixture.mjs';
import { interviewMessage, loadInterviewTemplate, northStarTemplateText, SPEC_PATH } from '../../project/src/onboarding.mjs';
const TEMPLATE = loadInterviewTemplate();
const DRAFT = TEMPLATE.drafts.northStar;
const CHAT_PROJECT = '20000000-0000-4000-8000-000000000002', THREAD = '60000000-0000-4000-8000-000000000001', MESSAGE = '70000000-0000-4000-8000-000000000001';
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 INSTANCES = { revision: 'f'.repeat(64), definitions: {}, instances: { coder: { definition: 'coder' }, assistant: { definition: 'assistant', vars: { 'persona.name': 'Ada' } } } };
const SIZES = [[1440, 900], [390, 844]];
const shots = process.env.MOSAIC_UX_SHOTS;
// The chat writes the interview makes, recorded; both answer the receipt shape chat-http checks.
function chatFake(rows = []) {
const calls = { createThread: [], send: [] };
return {
calls,
chat: {
projects: async () => rows, threads: async () => [], messages: async () => [],
createThread: async args => { calls.createThread.push(args); return { id: THREAD, instance: args.instance }; },
send: async args => { calls.send.push(args); return { id: MESSAGE }; },
},
};
}
// Polls from Node, so a page reload during the wait is not an error.
async function until(b, expression, what = expression) {
for (let i = 0; i < 300; i++) {
try { if (await b.evaluate(expression)) return; } catch { /* the page is reloading */ }
await new Promise(r => setTimeout(r, 30));
}
throw Error(`Timed out: ${what}`);
}
const shown = (b, selector) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && e.getClientRects().length > 0})()`);
async function capture(b, selector, name) {
if (!shots) return;
for (const [width, height] of SIZES) {
await b.viewport(width, height);
await b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block:'center'})`);
await new Promise(r => setTimeout(r, 150));
await b.screenshot(join(shots, `${name}-${width}.png`));
}
await b.viewport(...SIZES[0]);
}
for (const scenario of ['start', 'skip', 'stopped']) test(`quick setup done state offers the interview: ${scenario}`, { timeout: 60000 }, async t => {
const dir = mkdtempSync(join(tmpdir(), 'f2-'));
const configDir = join(dir, 'config'), dataRoot = join(dir, 'data'); mkdirSync(configDir, { mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
let starts = 0, instanceReads = 0;
const bus = { busStatus: async () => ({ healthy: false }), busStart: async () => { starts++; if (scenario === 'stopped') throw Object.assign(Error('bus refused'), { code: 'org-invalid' }); return { healthy: true }; } };
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir, dataRoot, instance: 'test' }) });
const rows = [], fake = chatFake(rows);
fake.chat.createProject = async args => { const row = { id: CHAT_PROJECT, ...args }; rows.push(row); return row; };
const organizations = {
list: async () => starts ? [{ id: 'alpha', label: 'alpha', busHealthy: true }] : [],
resolve: async id => id === 'alpha' ? { send: async () => ({}), busHealthy: true, chat: fake.chat, bus: { instances: async () => { instanceReads++; return structuredClone(INSTANCES); } } } : null,
};
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin: admin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
t.after(() => rmSync(dir, { recursive: true, force: true }));
await b.viewport(...SIZES[0]);
await b.navigate(`http://127.0.0.1:${server.address().port}/#/settings/organizations`);
await until(b, 'document.querySelector("#org-new-form [data-write]") && !document.querySelector("#org-new-form [data-write]").disabled');
await b.evaluate(`document.querySelector('#org-new-id').value='alpha';document.querySelector('#org-new-form [data-write]').click()`);
await until(b, 'document.querySelector("#org-quick-open") && !document.querySelector("#org-paths").hidden');
// The deterministic Test model only: choose it by name, never the first available preset.
await b.evaluate(`(()=>{const s=document.querySelector('#org-preset');s.value='fixture';s.dispatchEvent(new Event('change'))})()`);
assert.deepEqual(await b.evaluate(`(()=>{const s=document.querySelector('#org-preset');return [s.value,s.selectedOptions[0]?.textContent]})()`), ['fixture', 'Test model']);
assert.equal(await shown(b, '#org-quick-interview'), false, 'no offer before a run');
await b.evaluate(`document.querySelector('#org-quick-open').click();document.querySelector('#org-risk-accept').click();document.querySelector('#org-quick-run').click()`);
if (scenario === 'stopped') {
await until(b, `document.querySelector('[data-quick-step="3"] .org-pill')?.textContent === 'Stopped'`);
assert.equal(await shown(b, '#org-quick-interview'), false, 'a stopped setup offers no interview');
assert.equal(instanceReads, 0); assert.deepEqual(fake.calls, { createThread: [], send: [] });
return;
}
await until(b, `document.querySelector('[data-quick-step="5"] .org-pill')?.textContent === 'Done'`);
await until(b, '!document.querySelector("#org-quick-interview").hidden && !document.querySelector("#org-quick-open-org").hidden');
assert.equal(await b.evaluate(`document.querySelector('#org-quick-interview').textContent`), 'Start onboarding interview');
assert.equal(await shown(b, '#org-quick-open-org'), true, 'Open organization stays next to it');
// Both controls are in one row of actions, each a 44px target.
for (const id of ['#org-quick-interview', '#org-quick-open-org']) assert.ok(await b.evaluate(`document.querySelector('${id}').getBoundingClientRect().height >= 44`), id);
if (scenario === 'start') await capture(b, '#org-quick-interview', 'quick-setup-done');
if (scenario === 'skip') {
await b.evaluate(`document.querySelector('#org-quick-open-org').click()`);
await until(b, `window.MosaicOrg?.id === 'alpha' && document.querySelector('.chat-projects')?.textContent.includes('Repository: work')`);
// The organization page reads its role instances for its own views; only the chat writes matter here.
assert.deepEqual(fake.calls, { createThread: [], send: [] }, 'skipping opens no conversation and sends nothing');
return;
}
await b.evaluate(`document.querySelector('#org-quick-interview').click()`);
await until(b, `location.hash === '#/orgs/alpha/projects/threads/${THREAD}' && window.MosaicOrg?.id === 'alpha'`, 'the interview conversation opens in alpha');
assert.deepEqual(fake.calls.createThread, [{ project: CHAT_PROJECT, name: TEMPLATE.threadName, instance: 'assistant' }]);
assert.deepEqual(fake.calls.send, [{ thread: THREAD, body: interviewMessage(TEMPLATE, { project: 'work' }) }]);
assert.ok(instanceReads >= 1, 'the interview read named the assistant from the organization\'s role instances');
});
test('the project page offers the interview while the north star is the template, then the draft with Approve', { timeout: 90000 }, async t => {
const state = { spec: { path: SPEC_PATH, head: S1, commit: S1, content: northStarTemplateText(), author: 'jason', at: '2026-11-09T10:00:00Z' }, draft: null };
const saves = [], fake = chatFake();
const server = await startServer({
port: 0, sso: createDevOwner(), send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {}, chat: fake.chat,
bus: { instances: async () => structuredClone(INSTANCES) },
projectFiles: async () => ({ head: state.draft?.commit ?? state.spec.commit, files: [{ path: SPEC_PATH }, ...(state.draft ? [{ path: DRAFT }] : [])] }),
projectFile: async ({ path }) => {
if (path === SPEC_PATH) return structuredClone(state.spec);
if (path === DRAFT && state.draft) return structuredClone(state.draft);
throw new BusReadError('project-read-missing');
},
projectSave: async args => {
saves.push(args);
state.spec = { ...state.spec, head: S2, commit: S2, content: args.content, at: '2026-11-09T12:00:00Z' };
return { path: args.path, commit: S2 };
},
});
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
// The shared stub answers the Projects page; this wrapper sends the reads and writes F2 adds to the server.
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.__serverFetch=window.fetch.bind(window);' });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(() => {
const stubbed = window.fetch, server = window.__serverFetch; window.serverCalls = [];
window.fetch = async (url, init = {}) => {
const path = String(url), method = init.method || 'GET';
const toServer = method === 'GET' ? /^\\/api\\/(project-files\\?|project-file\\?|onboarding\\/interview\\?)/.test(path)
: /^\\/api\\/(north-star\\/approve|chat\\/threads|chat\\/threads\\/[0-9a-f-]{36}\\/messages)$/.test(path);
if (toServer) { const r = await server(url, init); window.serverCalls.push(method + ' ' + path); return r; }
if (path === '/api/config') {
const [a, b] = await Promise.all([stubbed(url, init).then(r => r.json()), server(url, init).then(r => r.json())]);
return new Response(JSON.stringify({ ...a, ...b }), { headers: { 'content-type': 'application/json' } });
}
return stubbed(url, init);
};
})();` });
await b.viewport(...SIZES[0]);
const base = `http://127.0.0.1:${server.address().port}`;
const open = async n => {
await b.navigate(`${base}/?r=${n}#/projects/${P}`);
await until(b, `[...document.querySelectorAll('.project-panel h2')].some(h => h.textContent === 'Project direction')`, 'the overview shows the project direction');
};
// 1. The north star is still the template: the panel lists the questions and offers the interview.
await open(1);
await until(b, `document.querySelector('[data-onboarding] h2')?.textContent === 'Onboarding interview'`);
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll('[data-onboarding] .onboarding-questions li')].map(e=>e.textContent)`), TEMPLATE.questions.map(q => q.text));
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding-start]').textContent`), 'Start onboarding interview');
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding]').nextElementSibling.textContent.includes('Project direction')`), true, 'the panel sits just before the project content');
await capture(b, '[data-onboarding]', 'project-offer');
for (const [width, height] of SIZES) {
await b.viewport(width, height);
assert.ok(await b.evaluate(`(()=>{const p=document.querySelector('[data-onboarding]'),r=p.getBoundingClientRect();return p.scrollWidth<=p.clientWidth && r.right<=innerWidth+1})()`), `the panel fits at ${width}px`);
assert.ok(await b.evaluate(`document.querySelector('[data-onboarding-start]').getBoundingClientRect().height >= 44`), `a 44px start target at ${width}px`);
}
await b.viewport(...SIZES[0]);
await b.evaluate(`document.querySelector('[data-onboarding-start]').click()`);
await until(b, `location.hash === '#/projects/threads/${THREAD}'`, 'the interview conversation opens');
assert.deepEqual(fake.calls.createThread, [{ project: P, name: TEMPLATE.threadName, instance: 'assistant' }]);
assert.deepEqual(fake.calls.send, [{ thread: THREAD, body: interviewMessage(TEMPLATE, { project: 'timer' }) }]);
// 2. The assistant committed a draft after the north star: the panel shows it with Approve and the CLI verb.
state.draft = { path: DRAFT, head: D1, commit: D1, content: TEXT, author: 'assistant.assistant', at: '2026-11-09T11:00:00Z' };
await open(2);
await until(b, `document.querySelector('[data-onboarding] h2')?.textContent === 'North star draft ready'`);
assert.match(await b.evaluate(`document.querySelector('[data-onboarding-draft]').textContent`), /Onboard new organizations in one sitting\./);
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding] .onboarding-verb').textContent`), `Same as mosaic project north-star timer --approve ${D1} --base ${S1}`);
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding-start]')`), false, 'no second interview offer beside a draft');
await capture(b, '[data-onboarding]', 'project-draft');
assert.deepEqual(saves, [], 'showing the draft writes nothing');
await b.evaluate(`document.querySelector('[data-onboarding-approve]').click()`);
await until(b, `document.querySelector('[data-onboarding] .onboarding-status')?.textContent.startsWith('Approved.')`);
assert.equal(await b.evaluate(`document.querySelector('[data-onboarding] .onboarding-status').textContent`), `Approved. spec/north-star.md is now commit ${S2.slice(0, 12)}. The product manager and CTO get the spec notice.`);
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding-approve]')`), false);
assert.deepEqual(saves, [{ project: 'timer', path: SPEC_PATH, base: S1, content: TEXT }]);
assert.ok(await b.evaluate(`window.serverCalls.includes('POST /api/north-star/approve')`));
await capture(b, '[data-onboarding]', 'project-approved');
// 3. After a reload the spec holds the draft: nothing is offered.
await open(3);
await until(b, `window.serverCalls.some(c => c.includes(${JSON.stringify(encodeURIComponent(DRAFT))}))`, 'the draft read finished');
await new Promise(r => setTimeout(r, 300));
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding]')`), false, 'no offer once the north star is approved');
// 4. A north star typed by hand after the draft: the older draft is not offered again.
state.spec = { ...state.spec, commit: 'd'.repeat(40), content: '# Typed by hand\n', at: '2026-11-09T13:00:00Z' };
state.draft = { ...state.draft, at: '2026-11-09T11:00:00Z' };
await open(4);
await until(b, `window.serverCalls.some(c => c.includes(${JSON.stringify(encodeURIComponent(DRAFT))}))`, 'the draft read finished');
await new Promise(r => setTimeout(r, 300));
assert.equal(await b.evaluate(`!!document.querySelector('[data-onboarding]')`), false, 'an older draft is not offered over a newer north star');
});
@@ -0,0 +1,258 @@
// 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, /<script src="\/onboarding\.js"/); assert.match(page.text, /<link [^>]*href="\/onboarding\.css"/);
});