git mv packages/business -> packages/organization (@mosaic/organization). The old package stays as one-line re-export shims of each moved module, with src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs forwarding to the moved executable. BusinessError is OrganizationConfigError (one constructor, name kept BusinessError); OrganizationError stays distinct. The bus adapter gains organization-context.mjs (busOrganization), with business.mjs (busBusiness) as the same function. Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure walker (organization-imports.mjs, both roots required) move to the new path. v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage A contract tests stay where they were and pass as before.
176 lines
12 KiB
JavaScript
176 lines
12 KiB
JavaScript
// Row 65 (#1558, REQ-PLUG-2, mos-0840): the Console's routing table read and write. The write has
|
|
// the Settings boundary and makes one human-transport call, `routes.set`; the route rules are the
|
|
// bus's and the business writer's, tested there. The first test runs a real broker over a temporary
|
|
// organization directory, so a save reaches the file and the log, and each refusal leaves both.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { request } from 'node:http';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { BusReadError } from '../src/bus.mjs';
|
|
import { BusError } from '../../bus/src/broker.mjs';
|
|
import { startBroker } from '../../bus/src/runtime.mjs';
|
|
import { systemFor } from '../../organization/tests/helpers.mjs';
|
|
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
|
import { createSsoSession } from './helpers/sso-session.mjs';
|
|
|
|
const REV = 'a'.repeat(64);
|
|
const ROUTE = { harness: 'pi', model: 'glm-5.3-flash', thinking: 'low', fallback: null };
|
|
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), harnesses: ['pi'], models: ['glm-5.3-flash'], levels: { pi: ['low'] }, instances: { coder: ROUTE }, ...extra });
|
|
|
|
async function setup(t, { on = true, writer = on, write = async () => view(), bus = null } = {}) {
|
|
const calls = [], lines = [];
|
|
const auth = await createSsoSession(t, { host: 'console.example' });
|
|
const server = await startServer({
|
|
port: 0, sso: auth.sso, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
|
|
routesWrite: writer ? async args => { calls.push(args); return write(args); } : null,
|
|
});
|
|
t.after(() => new Promise(r => server.close(r)));
|
|
const session = await auth.signIn(server.address().port);
|
|
return { calls, lines, port: server.address().port, headers: auth.writeHeaders(session) };
|
|
}
|
|
const send = (s, path, data, hs = s.headers, method = 'POST') => 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, body: text ? JSON.parse(text) : null }));
|
|
});
|
|
req.on('error', reject);
|
|
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
|
|
});
|
|
const get = (s, path) => send(s, path, undefined, s.headers, 'GET');
|
|
|
|
test('row 65 Console: the signed-in human saves a route through a real broker; each refusal says nothing changed and leaves the file and the log', async t => {
|
|
const root = tmp(t, 'webui-routes-');
|
|
const f = fixture(root, 'acme');
|
|
const dir = join(root, 'config');
|
|
const host = await startBroker({
|
|
dataRoot: f.dataRoot,
|
|
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
|
|
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
|
|
});
|
|
t.after(() => host.close());
|
|
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const call = async (verb, args) => {
|
|
try { return structuredClone(host.broker.request(human, { verb, args })); } catch (e) {
|
|
if (e instanceof BusError) throw new BusReadError(e.code);
|
|
throw e;
|
|
}
|
|
};
|
|
const s = await setup(t, { bus: { routes: () => call('routes') }, write: args => call('routes.set', args) });
|
|
const file = join(dir, 'businesses', 'acme.json');
|
|
const log = () => readFileSync(join(dir, 'businesses', '.history', 'acme', 'changes.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
|
const read = await get(s, '/api/bus/routes');
|
|
assert.equal(read.status, 200);
|
|
const [current] = read.body.rows;
|
|
assert.deepEqual(current.harnesses, ['pi', 'claude-code']);
|
|
assert.deepEqual(current.levels['claude-code'], ['low', 'medium', 'high', 'xhigh', 'max']);
|
|
assert.deepEqual(current.instances.coder, { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high', fallback: null });
|
|
|
|
const route = { harness: 'claude-code', model: 'claude-opus-5-5', thinking: 'xhigh', fallback: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'off' } };
|
|
const saved = await send(s, '/api/routes', { revision: current.revision, routes: { coder: route } });
|
|
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
assert.deepEqual(saved.body.instances.coder, route);
|
|
assert.deepEqual(s.calls, [{ revision: current.revision, routes: { coder: route } }]);
|
|
assert.match(s.lines.at(-1), / routes user="jason" instances="coder" done$/);
|
|
const entry = log().at(-1);
|
|
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', saved.body.transaction]);
|
|
const vars = JSON.parse(readFileSync(file, 'utf8')).roles.coder.vars;
|
|
assert.deepEqual(['harness', 'model', 'thinking', 'fallback.harness', 'fallback.model', 'fallback.thinking'].map(k => vars[k]), ['claude-code', 'claude-opus-5-5', 'xhigh', 'pi', 'glm-5.3-flash', 'off']);
|
|
|
|
const before = readFileSync(file, 'utf8'), entries = log().length;
|
|
const coder = more => ({ coder: { ...route, ...more } });
|
|
for (const [routes, revision, status, code, why] of [
|
|
[coder({ harness: 'cursor' }), saved.body.revision, 400, 'harness-off', /isn't switched on in the business file's plugins\.harness/],
|
|
[coder({ thinking: 'minimal' }), saved.body.revision, 400, 'thinking-unsupported', /doesn't apply that thinking level/],
|
|
[coder({ fallback: { harness: 'pi', model: 'glm-5.3-flash' } }), saved.body.revision, 400, 'organization-partial-fallback', /sets its harness, model and thinking together, or is cleared/],
|
|
[{ ghost: route }, saved.body.revision, 400, 'organization-unknown-instance', /no such instance/],
|
|
[{ coder: route }, saved.body.revision, 400, 'organization-no-change', /already says that/],
|
|
[coder({ thinking: 'low' }), current.revision, 409, 'revision-conflict', /changed since you read it/],
|
|
]) {
|
|
const r = await send(s, '/api/routes', { revision, routes });
|
|
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, code, false], code);
|
|
assert.match(r.body.message, why, code);
|
|
assert.match(r.body.message, new RegExp(`^${code}: .*Nothing changed\\.$`), code);
|
|
}
|
|
assert.equal(readFileSync(file, 'utf8'), before);
|
|
assert.equal(log().length, entries);
|
|
});
|
|
|
|
test('row 65 Console: the routes write needs send on, a writer, the public name, the user, the exact Origin and same-origin Fetch Metadata', async t => {
|
|
const data = { revision: REV, routes: { coder: ROUTE } };
|
|
const off = await setup(t, { on: false });
|
|
assert.equal((await send(off, '/api/routes', data)).status, 403);
|
|
const unsent = await setup(t, { on: false, writer: true });
|
|
assert.equal((await send(unsent, '/api/routes', data)).status, 403);
|
|
assert.equal(unsent.calls.length, 0);
|
|
const unwritten = await setup(t, { writer: false });
|
|
assert.equal((await send(unwritten, '/api/routes', data)).status, 403);
|
|
const s = await setup(t);
|
|
for (const [key, value, status] of [['host', 'wrong.example', 403], ['cookie', '__Host-mosaic_session=invalid', 401], ['cookie', undefined, 401], ['origin', 'https://else.example', 403], ['origin', undefined, 403], ['sec-fetch-site', 'cross-site', 403], ['sec-fetch-site', undefined, 403]]) {
|
|
const hs = { ...s.headers };
|
|
if (value === undefined) delete hs[key]; else hs[key] = value;
|
|
assert.equal((await send(s, '/api/routes', data, hs)).status, status, key);
|
|
}
|
|
assert.equal((await send(s, '/api/routes', undefined, s.headers, 'GET')).status, 405);
|
|
assert.equal(s.calls.length, 0);
|
|
});
|
|
|
|
test('row 65 Console: the routes write takes JSON within 16 KiB with a revision and routes only; an actor is refused and routes are never logged', async t => {
|
|
const s = await setup(t);
|
|
const base = { revision: REV, routes: { coder: ROUTE } };
|
|
for (const value of [
|
|
{ ...base, actor: 'mos' }, { ...base, instances: { coder: 'x' } }, { revision: REV }, { routes: base.routes },
|
|
{ ...base, revision: 'stale' }, { ...base, revision: 7 }, { ...base, routes: {} }, { ...base, routes: [] }, { ...base, routes: null },
|
|
[], 'bad', { ...base, routes: { coder: { ...ROUTE, model: 'x'.repeat(17000) } } },
|
|
]) assert.equal((await send(s, '/api/routes', value)).status, 400, JSON.stringify(value).slice(0, 60));
|
|
assert.equal((await send(s, '/api/routes', base, { ...s.headers, 'content-type': 'text/plain' })).status, 400);
|
|
assert.equal(s.calls.length, 0);
|
|
// A route with a model the bus refuses: the log names the instance and the code, never the route.
|
|
const refused = await setup(t, { write: async () => { throw new BusReadError('unknown-model'); } });
|
|
await send(refused, '/api/routes', { revision: REV, routes: { coder: { ...ROUTE, model: 'secret-model-name' } } });
|
|
assert.deepEqual(refused.lines.map(l => l.replace(/^\S+ /, '')), ['routes user="jason" instances="coder" error=unknown-model']);
|
|
assert.ok([...s.lines, ...refused.lines].every(l => !/xxxx|secret-model-name|glm/.test(l)), 'routes are never logged');
|
|
});
|
|
|
|
test('row 65 Console: a bus refusal maps to its status and says nothing changed; a lost or odd answer says the change may have been made', async t => {
|
|
const fail = code => async () => { throw new BusReadError(code); };
|
|
for (const [write, status, error, sent] of [
|
|
[fail('harness-off'), 400, 'harness-off', false], [fail('harness-refused'), 400, 'harness-refused', false],
|
|
[fail('thinking-unsupported'), 400, 'thinking-unsupported', false], [fail('unknown-model'), 400, 'unknown-model', false],
|
|
[fail('organization-partial-fallback'), 400, 'organization-partial-fallback', false],
|
|
[fail('organization-unknown-instance'), 400, 'organization-unknown-instance', false],
|
|
[fail('organization-no-change'), 400, 'organization-no-change', false], [fail('revision-conflict'), 409, 'revision-conflict', false],
|
|
[fail('human-required'), 403, 'human-required', false], [fail('invalid-request'), 400, 'invalid-request', false],
|
|
[fail('outcome-unknown'), 503, 'outcome-unknown', 'unknown'], [async () => { throw new Error('lost'); }, 503, 'outcome-unknown', 'unknown'],
|
|
// Each part of the answer's shape wrong alone, and an answer without the instance sent.
|
|
[async () => view({ revision: 'x' }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ harnesses: null }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ models: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ levels: [] }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ instances: null }), 503, 'outcome-unknown', 'unknown'],
|
|
[async () => view({ instances: {} }), 503, 'outcome-unknown', 'unknown'],
|
|
]) {
|
|
const s = await setup(t, { write });
|
|
const r = await send(s, '/api/routes', { revision: REV, routes: { coder: ROUTE } });
|
|
assert.deepEqual([r.status, r.body.error, r.body.sent], [status, error, sent], error);
|
|
if (sent === false) assert.match(r.body.message, new RegExp(`^${error}: .*Nothing changed\\.$`), error);
|
|
else assert.match(r.body.message, /may have been made/, error);
|
|
assert.equal(s.calls.length, 1);
|
|
assert.match(s.lines[0], new RegExp(` routes user="jason" instances="coder" error=${error}$`));
|
|
}
|
|
});
|
|
|
|
test('row 65 Console: the routes read takes no arguments, needs a bus and refuses an odd answer', async t => {
|
|
const none = await setup(t);
|
|
assert.equal((await get(none, '/api/bus/routes')).status, 503);
|
|
const s = await setup(t, { bus: { routes: async () => view() } });
|
|
assert.equal((await get(s, '/api/bus/routes?x=1')).status, 400);
|
|
assert.deepEqual((await get(s, '/api/bus/routes')).body.rows, [view()]);
|
|
for (const odd of [{ revision: 'x' }, { harnesses: 'pi' }, { models: null }, { levels: [] }, { instances: [] }]) {
|
|
const r = await setup(t, { bus: { routes: async () => view(odd) } });
|
|
assert.equal((await get(r, '/api/bus/routes')).status, 502, JSON.stringify(odd));
|
|
}
|
|
const refused = await setup(t, { bus: { routes: async () => { throw new BusReadError('human-required'); } } });
|
|
assert.equal((await get(refused, '/api/bus/routes')).status, 403);
|
|
});
|