One Proxy setup panel on the Model proxy settings page takes an owner from
nothing to a running proxy: status with one next-step button per state, the
pinned fetch with hash checks, progress and a receipt, init with a
bus-generated key (a pasted key only over loopback or HTTPS, with a
confirmation), unit render/install/start/stop/restart with health, a fence
clear that the bus re-proves against a confirmed restart, and a stale init
lock clear that names its blockers. Own proxy per organization shows as not
built.
New Console routes under /api/proxy/admin/ use the existing owner proxy
checks; agents, testers and grant holders get human-required. New bus verbs
proxy.init {generate:true} and proxy.fence.clear. Receipts go to
<dataRoot>/state/proxy-admin.jsonl with labels and key ids only. Every new
refusal code has a refusal-table entry.
CLI: mosaic proxy status, unit render|status|install|start|stop|restart,
init --generate-key, fence clear --restart, init-lock status|clear; proxy
fetch keeps its stdout and adds progress and the receipt.
Page fix found in the end-to-end run: a stopped proxy hides the account
panels until the service answers. (The catalogue switch label fix found in
the same run landed upstream as D3 P0-4.)
1241 lines
100 KiB
JavaScript
1241 lines
100 KiB
JavaScript
import { AUTONOMY_ROUTE, autonomyHTTP } from './autonomy-http.mjs';
|
||
import { proxyHTTP } from './proxy-http.mjs';
|
||
import { proxyAdminHTTP } from './proxy-admin.mjs';
|
||
import { createServer } from 'node:http';
|
||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
||
import { timingSafeEqual, randomUUID } from 'node:crypto';
|
||
import { resolve } from 'node:path';
|
||
import { isIP } from 'node:net';
|
||
import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from './bus.mjs';
|
||
// 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_DEFINITION, THREAD_DEFINITIONS } from '../../organization/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';
|
||
import { themesHTTP } from './themes-http.mjs';
|
||
import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } from './decision-http.mjs';
|
||
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
|
||
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
|
||
import { SYSTEM_TYPES_ROUTE, systemTypesHTTP } from './system-types-http.mjs';
|
||
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
|
||
import { consoleRestartHTTP } from './console-restart-http.mjs';
|
||
import { workspaceHTTP } from './workspace-read.mjs';
|
||
import { agentLevelsHTTP } from './agent-levels-read.mjs';
|
||
import { TASK_WRITE_ROUTE, workspaceTasksHTTP } from './workspace-tasks.mjs';
|
||
import { actor } from './action-trail.mjs';
|
||
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
|
||
|
||
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
|
||
export function isLoopback(host) {
|
||
return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.'));
|
||
}
|
||
export function boardURL(value) {
|
||
const url = new URL(value);
|
||
if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
|
||
throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment');
|
||
}
|
||
// Avoid hostname resolution for localhost.
|
||
if (url.hostname === 'localhost') url.hostname = '127.0.0.1';
|
||
return url.origin;
|
||
}
|
||
const root = resolve(import.meta.dirname, 'public');
|
||
// A stream registers its HTTP module once: registerRoutes(table), calling
|
||
// table.add(method, absolutePathOrRegex, handler). Hooks run after session,
|
||
// origin and (for scoped paths) organization checks.
|
||
export function routeTable() {
|
||
const routes = [];
|
||
return Object.freeze({
|
||
add(method, pattern, handler, { publicRead = false } = {}) {
|
||
if (!['GET', 'POST'].includes(method) || !(typeof pattern === 'string' && pattern.startsWith('/api/') || pattern instanceof RegExp && pattern.source.startsWith('^\\/api\\/')) || typeof handler !== 'function') throw new TypeError('invalid Console route');
|
||
if (publicRead && method !== 'GET') throw new TypeError('public route must be a read');
|
||
routes.push({ method, pattern, handler, publicRead });
|
||
},
|
||
find(method, path) {
|
||
return routes.find(route => route.method === method && (typeof route.pattern === 'string' ? route.pattern === path : (route.pattern.lastIndex = 0, route.pattern.test(path))));
|
||
},
|
||
});
|
||
}
|
||
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', 'console-restart.js', 'onboarding.js', 'workspace-data.js', 'workspace.js', 'tasks-board.js', 'panes.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', 'onboarding.css', 'tasks-board.css', 'panes.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']],
|
||
['/requests.css', ['requests.css', 'text/css; charset=utf-8']],
|
||
['/editor.js', ['editor.js', 'text/javascript; charset=utf-8']],
|
||
// Row 225 (C2-10b): the one reply renderer, shared with the Discord relay, imported by chat.js.
|
||
['/render.mjs', ['../../../library/src/render.mjs', 'text/javascript; charset=utf-8']],
|
||
['/editor.css', ['editor.css', 'text/css; charset=utf-8']],
|
||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
|
||
// Phase P2 (#1873): the owner's System types control (dashboard section and project selection).
|
||
['/autonomy.js', ['autonomy.js', 'text/javascript; charset=utf-8']],
|
||
['/autonomy.css', ['autonomy.css', 'text/css; charset=utf-8']],
|
||
['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']],
|
||
['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']],
|
||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||
...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]),
|
||
]);
|
||
// FED R6 (#1800): a granted user's page. The owner page's markup and scripts are never served to a
|
||
// user session, so its controls are not sent at all (design 3.1 D5).
|
||
const USER_FILES = new Map([
|
||
['/', ['user.html', 'text/html; charset=utf-8']],
|
||
...['user.js', 'chat.js', 'federation.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]),
|
||
['/render.mjs', files.get('/render.mjs')],
|
||
...['tokens.css', 'console.css', 'chat.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]),
|
||
...[...files].filter(([p]) => p.startsWith('/assets/fonts/') || p === '/icons.svg'),
|
||
]);
|
||
// The only routes a granted user's session reaches: the chat, the page config and the user page.
|
||
// Anything else is the owner's, so a route added later is owner-only until it is added here.
|
||
export function userRoute(path, method) {
|
||
if (path === '/api/chat' || path.startsWith('/api/chat/')) return true;
|
||
if (method !== 'GET' && method !== 'HEAD') return false;
|
||
return path === '/api/config' || path === '/favicon.ico' || USER_FILES.has(path);
|
||
}
|
||
// The hub label and plain peer links, from system.federation: ids, labels and origins only.
|
||
function federationView(federation) {
|
||
if (federation === null || typeof federation !== 'object') return null;
|
||
return { self: federation.self ?? null, peers: (federation.peers ?? []).map(({ id, label, origin }) => ({ id, label, origin })) };
|
||
}
|
||
function json(res, status, body) {
|
||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||
res.end(JSON.stringify(body));
|
||
}
|
||
async function body(req, max = 4096) {
|
||
if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new Error('Content-Type must be application/json');
|
||
const chunks = [];
|
||
let size = 0;
|
||
for await (const chunk of req) {
|
||
size += chunk.length;
|
||
if (size > max) throw new Error(`body larger than ${max} bytes`);
|
||
chunks.push(chunk);
|
||
}
|
||
const bytes = Buffer.concat(chunks);
|
||
const value = JSON.parse(bytes.toString('utf8'));
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object');
|
||
return bytes;
|
||
}
|
||
// Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4).
|
||
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
||
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
|
||
// (#1575) adds `replies`: the messages answering one message, for the send receipt. Row 125
|
||
// (#1619, REQ-LIMIT-3) adds `limits`: only the token limits' report, never the document. Row 155
|
||
// (#1649) adds `names`: each instance's current seat name, for the Assistant section. Row 165
|
||
// (#1662) adds `instances`: the business file's role instances and the definitions add accepts.
|
||
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
|
||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'thread.claims', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
|
||
const DECISION_LISTS = ['roles', 'recent'];
|
||
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
|
||
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
|
||
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
|
||
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
|
||
// #797 MVP3: the owner's archive/unarchive writes (project-archive-http.mjs).
|
||
const PROJECT_ARCHIVE_ROUTE = /^\/api\/chat\/projects\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:archive|unarchive)$/i;
|
||
async function busRead(res, bus, verb, search) {
|
||
let arg;
|
||
if (verb === 'thread.claims' && search) return json(res, 400, { error: 'invalid-request', message: 'thread.claims takes no arguments' });
|
||
if (verb === 'usage') {
|
||
if (search) return json(res, 400, { error: 'invalid-request', message: 'usage takes no arguments' });
|
||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const value = await bus.usage();
|
||
if (!value || typeof value.total !== 'object' || !Array.isArray(value.byInstance)) throw new BusReadError('invalid-response');
|
||
return json(res, 200, { rows: [value], at: new Date().toISOString() });
|
||
} catch (err) {
|
||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||
return json(res, busStatus(code), { error: code, message: code });
|
||
}
|
||
}
|
||
if (verb === 'limits') {
|
||
if (search) return json(res, 400, { error: 'invalid-request', message: 'limits takes no arguments' });
|
||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const value = await bus.limits();
|
||
if (!value || !Array.isArray(value.tokens)) throw new BusReadError('invalid-response');
|
||
return json(res, 200, { rows: value.tokens, at: new Date().toISOString() });
|
||
} catch (err) {
|
||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||
return json(res, busStatus(code), { error: code, message: code });
|
||
}
|
||
}
|
||
if (verb === 'instances') {
|
||
if (search) return json(res, 400, { error: 'invalid-request', message: 'instances takes no arguments' });
|
||
if (!bus?.instances) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const value = await bus.instances();
|
||
if (!/^[a-f0-9]{64}$/.test(value?.revision ?? '') || !value.instances || typeof value.instances !== 'object' || !value.definitions || typeof value.definitions !== 'object') throw new BusReadError('invalid-response');
|
||
return json(res, 200, { rows: [value], at: new Date().toISOString() });
|
||
} catch (err) {
|
||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||
return json(res, busStatus(code), { error: code, message: code });
|
||
}
|
||
}
|
||
if (verb === 'routes') {
|
||
if (search) return json(res, 400, { error: 'invalid-request', message: 'routes takes no arguments' });
|
||
if (!bus?.routes) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const value = await bus.routes();
|
||
if (!routesAnswer(value)) throw new BusReadError('invalid-response');
|
||
return json(res, 200, { rows: [value], at: new Date().toISOString() });
|
||
} catch (err) {
|
||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||
return json(res, busStatus(code), { error: code, message: code });
|
||
}
|
||
}
|
||
if (verb === 'trail') {
|
||
arg = new URLSearchParams(search).get('subject');
|
||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' });
|
||
}
|
||
if (verb === 'decisions') {
|
||
arg = new URLSearchParams(search).get('list');
|
||
if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' });
|
||
}
|
||
if (verb === 'replies') {
|
||
arg = new URLSearchParams(search).get('id');
|
||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
|
||
}
|
||
if (verb === 'messages') {
|
||
const query = new URLSearchParams(search), args = {};
|
||
const invalid = [...query.keys()].some(k => !['limit', 'after'].includes(k) || query.getAll(k).length !== 1);
|
||
if (invalid || (query.has('limit') && (!/^[1-9]\d*$/.test(query.get('limit')) || Number(query.get('limit')) > 200)) ||
|
||
(query.has('after') && !subjectOk(query.get('after'))))
|
||
return json(res, 400, { error: 'invalid-request', message: 'messages takes limit (1–200) and an after message id' });
|
||
if (query.has('limit')) args.limit = Number(query.get('limit'));
|
||
if (query.has('after')) args.after = query.get('after');
|
||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const page = await bus.messages(args);
|
||
if (!page || !Array.isArray(page.messages)) throw new BusReadError('invalid-response');
|
||
const { messages: rows, ...metadata } = page;
|
||
return json(res, 200, { rows, ...metadata, 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 });
|
||
}
|
||
}
|
||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const rows = await bus[verb](arg);
|
||
if (!Array.isArray(rows)) throw new BusReadError('invalid-response');
|
||
return json(res, 200, { rows, 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 });
|
||
}
|
||
}
|
||
// Row 76 (#1570, lead decision 95): with OIDC --sso --send, a human message to a role.
|
||
// Checked here again whatever passed above: the session on this request, its own
|
||
// Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB, and only these keys. `to` and the body are the
|
||
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
|
||
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
|
||
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
|
||
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
|
||
// so it carries `sso: true` and the public origin. The header-preview signer is read-only.
|
||
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
|
||
const ownOrigin = p => p.origin ?? `https://${p.host}`;
|
||
async function busSend(req, res, { send, preview, log }) {
|
||
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
|
||
let to = null;
|
||
// `to` is quoted and cut short so a crafted value can't forge a log line.
|
||
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
|
||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
|
||
let value;
|
||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||
if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !SEND_KEYS.includes(k)) || !('to' in value) || !('body' in value)) return refuse(400, 'invalid-request', 'a send takes to, body and an optional in_reply_to, nothing else');
|
||
({ to } = value);
|
||
const args = { to: value.to, body: value.body };
|
||
if ('in_reply_to' in value) args.in_reply_to = value.in_reply_to;
|
||
let result;
|
||
try { result = await send(args); } catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
return sent === false
|
||
? refuse(busStatus(code), code, `not sent: the bus refused it (${code})`)
|
||
: refuse(503, 'outcome-unknown', 'the message may have been sent; nothing was retried. Check the trail before sending again', 'unknown');
|
||
}
|
||
if (!subjectOk(result?.id)) return refuse(503, 'outcome-unknown', 'the bus answered without a message id; the message may have been sent. Check the trail before sending again', 'unknown');
|
||
line(`id=${result.id}`);
|
||
return json(res, 200, { id: result.id, at: new Date().toISOString() });
|
||
}
|
||
// Row 54 (#1543): GET only. `reads` is consoleReads() from reads.mjs; its
|
||
// bodies and messages arrive redacted. No route here writes or calls out.
|
||
const QUEUE_ID = /^[1-9]\d{0,5}$/;
|
||
async function consoleRead(res, read) {
|
||
try { return json(res, 200, await read()); } catch (err) {
|
||
const code = err instanceof ReadError ? err.code : 'invalid-response';
|
||
return json(res, readStatus(code), { error: code, message: err instanceof ReadError ? err.message : code });
|
||
}
|
||
}
|
||
// Row 74 (#1568, lead decision 93): the preview mode behind the edge proxy. Off unless
|
||
// all three are given. The bind stays loopback; the proxy signs the user in
|
||
// and sets the header; the Console checks it against the one user id and serves reads only.
|
||
// Nothing here names an identity provider: one header name and one user id, from the flags.
|
||
const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
||
const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||
const USER = /^[\x21-\x2b\x2d-\x7e]{1,256}$/;
|
||
export function previewOptions({ publicHost, authHeader, allowUser } = {}) {
|
||
const given = [publicHost, authHeader, allowUser].filter(v => v !== undefined && v !== null);
|
||
if (!given.length) return null;
|
||
if (given.length !== 3) throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||
const host = String(publicHost).toLowerCase(), header = String(authHeader).toLowerCase();
|
||
if (!HOSTNAME.test(host) || isIP(host)) throw new Error('public host must be a DNS name without scheme or port');
|
||
if (!HEADER.test(header) || ['host', 'origin'].includes(header)) throw new Error('auth header must be a header name other than Host or Origin');
|
||
if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma');
|
||
return { host, header, user: allowUser };
|
||
}
|
||
// Row 178: read the proof once, without following a final symlink. Refusals never
|
||
// include the path, bytes, or an underlying fs error. The tester gets only its header name.
|
||
export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
|
||
if (proofHeader === undefined && proofFile === undefined) return null;
|
||
if (!proofHeader || !proofFile || !preview) throw new Error('proxy proof needs both flags and preview mode');
|
||
const header = String(proofHeader).toLowerCase();
|
||
if (!HEADER.test(header) || ['host', 'origin', 'x-mosaic-tester', preview.header].includes(header)) {
|
||
throw new Error('proxy proof header must be distinct from Host, Origin, tester and auth headers');
|
||
}
|
||
let fd;
|
||
try {
|
||
fd = openSync(proofFile, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||
const st = fstatSync(fd);
|
||
if (!st.isFile() || st.uid !== process.getuid() || ![0o600, 0o400].includes(st.mode & 0o7777)) {
|
||
throw new Error('invalid proof metadata');
|
||
}
|
||
const text = readFileSync(fd, 'utf8');
|
||
const line = text.endsWith('\n') ? text.slice(0, -1) : text;
|
||
if (line.length < 32 || /[^A-Za-z0-9_-]/.test(line)) throw new Error('invalid proof bytes');
|
||
return { header, value: Buffer.from(line, 'ascii') };
|
||
} catch {
|
||
throw new Error('proxy proof file must be a regular owned 0400 or 0600 file with one base64url line of at least 32 bytes');
|
||
} finally {
|
||
if (fd !== undefined) closeSync(fd);
|
||
}
|
||
}
|
||
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
|
||
async function settingsSend(req, res, { settingsWrite, preview, log }) {
|
||
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
|
||
if (!signedIn(req, preview)) {
|
||
return refuse(401, 'sign-in required', 'sign in again');
|
||
}
|
||
if (one(req, 'origin') !== ownOrigin(preview)) {
|
||
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
|
||
}
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') {
|
||
return refuse(403, 'cross-site request refused', 'settings need same-origin Fetch Metadata');
|
||
}
|
||
let value;
|
||
try {
|
||
value = JSON.parse((await body(req, 16384)).toString('utf8'));
|
||
} catch {
|
||
return refuse(400, 'invalid-request', 'settings need a JSON object within 16 KiB');
|
||
}
|
||
// Row 155 (#1649, Q169): `assistants` carries an assistant's vars, tracker and definition.
|
||
// Row 125 (#1619, REQ-LIMIT-3): `reset` names token limit paths to count again from now.
|
||
const writable = ['limits', 'models', 'instances', 'reset', 'assistants'];
|
||
if (Object.keys(value).some(key => !['revision', ...writable].includes(key)) ||
|
||
typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||
!writable.some(key => Object.hasOwn(value, key))) {
|
||
return refuse(400, 'invalid-request', 'settings need a revision and limits, models, instance selections, a reset or assistant settings');
|
||
}
|
||
try {
|
||
const result = await settingsWrite(value);
|
||
log(`${new Date().toISOString()} settings user=${JSON.stringify(preview.user)} changed`);
|
||
return json(res, 200, result);
|
||
} catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
log(`${new Date().toISOString()} settings user=${JSON.stringify(preview.user)} error=${code}`);
|
||
if (sent === 'unknown') {
|
||
return json(res, 503, {
|
||
error: 'outcome-unknown', message: 'Outcome unknown. Read settings before trying again.', sent: 'unknown',
|
||
});
|
||
}
|
||
return refuse(busStatus(code), code, 'Settings refused; read the current values before trying again.');
|
||
}
|
||
}
|
||
// Row 155 (#1649, Q169): the human names a seat and stops the assistant. Each route has the
|
||
// Settings boundary (header user, exact Origin, same-origin Fetch Metadata, JSON within 4 KiB,
|
||
// strict keys, all text) and one bus call through the human transport, `name.set` or
|
||
// `assistant.stop`. The broker checks the human and the instance.
|
||
const SEAT_ROUTES = {
|
||
'/api/name': { kind: 'name', keys: ['instance', 'name'], done: r => typeof r?.instance === 'string' && typeof r.name === 'string' },
|
||
'/api/assistant-stop': { kind: 'assistant-stop', keys: ['instance'], done: r => Array.isArray(r?.stopped) && r.stopped.every(x => typeof x?.thread === 'string' && typeof x.run === 'string') },
|
||
};
|
||
async function seatSend(req, res, { route, write, preview, log }) {
|
||
let instance = null;
|
||
// The instance is quoted and cut short so a crafted value can't forge a log line.
|
||
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
|
||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||
if (!preview.tester && !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', 'this needs the Console\'s own Origin');
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this 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 (Object.keys(value).length !== route.keys.length || !route.keys.every(k => typeof value[k] === 'string')) {
|
||
return refuse(400, 'invalid-request', `this takes ${route.keys.join(' and ')}, all text, nothing else`);
|
||
}
|
||
({ instance } = value);
|
||
let result;
|
||
try { result = await write(Object.fromEntries(route.keys.map(k => [k, value[k]]))); } catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
return sent === false
|
||
? refuse(busStatus(code), code, `refused by the bus (${code}); nothing changed`)
|
||
: refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the Settings again before trying', 'unknown');
|
||
}
|
||
if (!route.done(result)) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the Settings again before trying', 'unknown');
|
||
line('done');
|
||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||
}
|
||
// Row 165 (#1662, Q171): the human adds a role instance or changes one's definition, vars and
|
||
// launch membership. The Settings boundary (header user, exact Origin, same-origin Fetch Metadata,
|
||
// JSON within 16 KiB, strict keys) and one bus call, `instances.set`, through the human transport.
|
||
// The writer refuses a change to a definition that declares credentials. Row 194 (brief item 7):
|
||
// an add of one gets store references, and a vikunja one needs a bot, which this route doesn't
|
||
// take (sage-1610 a), so it refuses organization-tracker-required. The answer's `restart` says whether
|
||
// the running bus must restart before the change counts; the Console never restarts it.
|
||
const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch'];
|
||
// The bus answers a refusal with its code alone, so the reason the page shows is here.
|
||
const INSTANCE_REASONS = {
|
||
'organization-credentials-out-of-scope': 'that definition declares credentials, or the instance holds them. The Console binds no credentials, so that stays a hand edit of the business file',
|
||
'organization-tracker-required': 'that definition uses vikunja and needs a Vikunja bot. The Console names no bot; use mosaic org instance add with --bot and --bot-id',
|
||
'organization-tracker-bot-taken': 'another instance already uses that Vikunja bot',
|
||
'organization-instance-exists': 'the business already has that instance; change it instead',
|
||
'organization-unknown-instance': 'the business has no such instance; add it instead',
|
||
'organization-definition-refused': 'this form neither adds nor changes an assistant, and moves no instance to the assistant definition; an assistant changes only in the Assistant section',
|
||
'organization-launch-not-configured': 'the business file has no launch block, so launch membership can\'t change',
|
||
'organization-no-change': 'the business file already says that',
|
||
'organization-project-mismatch': 'a project file\'s id differs from its key in the business file; mosaic org config validate shows it',
|
||
'organization-unresolvable-instance': 'an instance wouldn\'t resolve against a project file; mosaic org config validate shows why',
|
||
'revision-conflict': 'the business file changed since you read it. Read the role instances again',
|
||
};
|
||
// Row 194 (#1692, D7): the lifecycle and veto refusals, for the delete op, the Agents routes and a
|
||
// closed request's refusal. A code missing here shows as itself.
|
||
const AGENT_REASONS = {
|
||
'organization-instance-referenced': 'the instance is launch.by or an arbiter; change that reference first',
|
||
'organization-instance-kept': 'the instance is kept; unkeep it first',
|
||
'organization-instance-retired': 'that name was deleted and stays retired; pick another name',
|
||
'instance-kept': 'the instance is kept; unkeep it first',
|
||
'instance-running': 'the instance has a run; terminate it first',
|
||
'instance-starting': 'a request for the instance has not closed yet; wait for it and try again',
|
||
'instance-retired': 'that name was deleted and launches no more',
|
||
'not-thread-launch': 'a thread instance starts on a thread message, not by launch',
|
||
'regenerate-thread': 'a thread instance isn\'t regenerated; its context is the thread\'s',
|
||
'not-held': 'the instance has no run and no open request',
|
||
'launch-revoked': 'launches are revoked for the business',
|
||
'launches-disabled': 'the bus host was started with --no-launch',
|
||
'request-expired': 'the launcher didn\'t take the request within its 60 s',
|
||
'request-cancelled': 'a terminate cancelled the request',
|
||
'regenerate-timeout': 'the old run didn\'t exit within the request\'s 60 s, so no new run started; it still runs',
|
||
'workspace-rename-failed': 'the business file is changed and the name retired, but the workspace stayed where it was; delete it again to retry the rename',
|
||
// Brief item 8, deviation 11 (sage-1620): the revoke runs before the write, so a retry is safe.
|
||
'credential-revoke-failed': 'a credential couldn\'t be revoked, so the business file is unchanged. Tokens already revoked stay revoked and the instance can\'t use them. Clear what else the agent\'s store directory holds and delete again, which is safe',
|
||
// Row 239 (#1745): a fire's or a delete's revoke runs under the writer's lock; these follow one that ran.
|
||
'revision-conflict-revoked': 'the business file changed after the agent\'s tokens were revoked and before the write, so the file is unchanged. The tokens may already be revoked and the instance can\'t use them. Read the role instances again and delete again, which is safe',
|
||
'outcome-unknown-revoked': 'the agent\'s tokens may already be revoked and the write\'s outcome is unknown. Read the role instances again before trying',
|
||
'capacity-full': 'a launch limit is reached',
|
||
'limit-reached': 'a launch limit is reached',
|
||
'no-route': 'the instance has no harness, model and thinking yet; set its route first',
|
||
'skill-not-enabled': "A skill this role needs isn't turned on: scripts/skill.sh activate <name>",
|
||
'unsupported-harness': 'Choose a supported harness in Routing',
|
||
'unknown-role': 'the business has no such instance',
|
||
'unknown-request': 'the bus has no such request',
|
||
'instance-not-listed': 'the instance isn\'t on the launch list',
|
||
// #1873 A3: a root or project copy that can't launch. The Agents grid names the agent and the
|
||
// missing service or the reason; these lines carry no value.
|
||
'credential-unbound': 'the agent needs a credential service this organization has no binding for; the Agents grid names it',
|
||
'copy-refused': 'this organization refused its copy of the root agent; the Agents grid says why',
|
||
'project-refused': 'the project agent was refused, or its project refuses its agents; the Agents grid says why',
|
||
'project-agent-elsewhere': 'a project agent works only in its own project',
|
||
};
|
||
const reasonFor = (code, done) => {
|
||
if (code === 'skill-not-enabled' && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(done?.skill ?? ''))
|
||
return `A skill this role needs isn't turned on: scripts/skill.sh activate ${done.skill}`;
|
||
return Object.hasOwn(INSTANCE_REASONS, code) ? INSTANCE_REASONS[code] : Object.hasOwn(AGENT_REASONS, code) ? AGENT_REASONS[code] : typeof code === 'string' && code.startsWith('proxy-') ? 'Check the model proxy configuration and account status' : String(code ?? 'unknown');
|
||
};
|
||
async function instanceSend(req, res, { instancesWrite, preview, log, note = null }) {
|
||
let instance = null, op = null;
|
||
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
|
||
// F3 review P3: the owner's instance changes also go to the action trail, like routes and agents.
|
||
const line = outcome => { log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`); note?.({ action: 'instances.set', op, instance: typeof instance === 'string' ? instance.slice(0, 200) : null, outcome }); };
|
||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||
if (!preview.tester && !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', 'this needs the Console\'s own Origin');
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||
let value;
|
||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||
const has = key => Object.hasOwn(value, key);
|
||
// Row 194 (#1692, D7, V5): op delete takes the revision and the instance alone. The bus answers a
|
||
// request the launcher's pass carries out, which the page follows at /api/agent/request.
|
||
const deleting = value?.op === 'delete';
|
||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !INSTANCE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||
typeof value.instance !== 'string' || !['add', 'change', 'delete'].includes(value.op) || (deleting && ['definition', 'vars', 'launch'].some(has)) || (has('definition') && typeof value.definition !== 'string') ||
|
||
(has('vars') && (!value.vars || typeof value.vars !== 'object' || Array.isArray(value.vars))) || (has('launch') && typeof value.launch !== 'boolean')) {
|
||
return refuse(400, 'invalid-request', 'this takes a revision, an instance, op add or change and a definition, vars or launch, or op delete alone, nothing else');
|
||
}
|
||
({ instance, op } = value);
|
||
// D4.1: the tester never deletes. The bus refuses it too (broker tester-refused); this makes no call.
|
||
if (deleting && preview.tester) return refuse(403, 'tester-refused', 'the tester listener doesn\'t delete. Nothing changed.');
|
||
let result;
|
||
try { result = await instancesWrite(Object.fromEntries(INSTANCE_KEYS.filter(has).map(k => [k, value[k]]))); } catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
return sent === false
|
||
? refuse(busStatus(code), code, `${code}: ${reasonFor(code)}. Nothing changed.`)
|
||
: refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the role instances again before trying', 'unknown');
|
||
}
|
||
if (deleting) {
|
||
if (!REQUEST_ID.test(result?.request ?? '')) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the delete may have been asked for. Read the role instances again before trying', 'unknown');
|
||
line(`done request=${result.request}`);
|
||
return json(res, 200, { request: result.request, at: new Date().toISOString() });
|
||
}
|
||
const object = o => o && typeof o === 'object' && !Array.isArray(o);
|
||
if (!/^[a-f0-9]{64}$/.test(result?.revision ?? '') || typeof result.restart !== 'boolean' || !object(result.definitions) || !object(result.instances) || !result.instances[instance]) {
|
||
return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the role instances again before trying', 'unknown');
|
||
}
|
||
line(`done restart=${result.restart}`);
|
||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||
}
|
||
// Row 194 (#1692, D7): the Agents section's launch, regenerate, terminate and veto. Each has
|
||
// instanceSend's boundary and one human-transport call. Launch and regenerate answer a request the
|
||
// page follows at /api/agent/request until it closes. `tester` marks the routes the tester listener
|
||
// lets through (D4.1): launch, terminate and the request read.
|
||
const REQUEST_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||
const VETO_OPS = ['keep', 'unkeep', 'nohire', 'hire'];
|
||
const ids = list => Array.isArray(list) && list.every(x => typeof x === 'string');
|
||
const AGENT_ROUTES = {
|
||
'/api/agent/launch': { kind: 'launch', verb: 'instance.launch', keys: ['instance'], tester: true, done: r => REQUEST_ID.test(r?.request ?? '') },
|
||
'/api/agent/regenerate': { kind: 'regenerate', verb: 'instance.regenerate', keys: ['instance'], tester: false, done: r => REQUEST_ID.test(r?.request ?? '') },
|
||
'/api/agent/terminate': { kind: 'terminate', verb: 'instance.end', keys: ['instance', 'reason'], tester: true,
|
||
done: r => REQUEST_ID.test(r?.request ?? '') && ids(r.runs) && ids(r.cancelled) && ids(r.taken) },
|
||
'/api/veto': { kind: 'veto', verb: 'veto.set', keys: ['revision', 'op', 'name'], tester: false,
|
||
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
|
||
};
|
||
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
|
||
const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|
||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/')
|
||
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|
||
|| SYSTEM_TYPES_ROUTE.test(path);
|
||
async function agentSend(req, res, { route, agentWrite, preview, log, note = null }) {
|
||
let target = null;
|
||
// The instance or name is quoted and cut short so a crafted value can't forge a log line. A reason is never logged.
|
||
const line = outcome => { log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(target)} ${outcome}`); note?.({ action: route.verb, instance: typeof target === 'string' ? target.slice(0, 200) : null, outcome }); };
|
||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||
if (!preview.tester && !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', 'this needs the Console\'s own Origin');
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||
let value;
|
||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||
const required = route.keys.filter(k => k !== 'reason');
|
||
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !route.keys.includes(k)) || !required.every(k => Object.hasOwn(value, k)) ||
|
||
!Object.values(value).every(v => typeof v === 'string') || (Object.hasOwn(value, 'revision') && !/^[a-f0-9]{64}$/.test(value.revision)) ||
|
||
(Object.hasOwn(value, 'op') && !VETO_OPS.includes(value.op))) {
|
||
return refuse(400, 'invalid-request', `this takes ${route.keys.join(', ')}${route.kind === 'terminate' ? ' (the reason may be left out)' : ''}, all text, nothing else`);
|
||
}
|
||
target = value.instance ?? value.name;
|
||
let result;
|
||
try { result = await agentWrite(route.verb, Object.fromEntries(route.keys.filter(k => Object.hasOwn(value, k)).map(k => [k, value[k]]))); } catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
return sent === false
|
||
? refuse(busStatus(code), code, `${code}: ${reasonFor(code)}. Nothing changed.`)
|
||
: refuse(503, 'outcome-unknown', 'the request may have been made; nothing was retried. Read the agents again before trying', 'unknown');
|
||
}
|
||
if (!route.done(result)) return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the request may have been made. Read the agents again before trying', 'unknown');
|
||
line(result.request ? `done request=${result.request}` : 'done');
|
||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||
}
|
||
// D1.1 item 5: the read the page polls. A closed request's refusal gains its reason.
|
||
const REQUEST_STATES = ['open', 'taken', 'closed'];
|
||
async function agentRequest(res, { agentWrite, search }) {
|
||
const params = new URLSearchParams(search);
|
||
const id = params.get('id');
|
||
if ([...params.keys()].some(k => k !== 'id') || params.getAll('id').length !== 1 || !REQUEST_ID.test(id)) {
|
||
return json(res, 400, { error: 'invalid-request', message: 'this takes one request id' });
|
||
}
|
||
let r;
|
||
try { r = await agentWrite('instance.request', { request: id }); } catch (err) {
|
||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||
return json(res, busStatus(code), { error: code, message: `${code}: ${reasonFor(code)}` });
|
||
}
|
||
if (r?.request !== id || !REQUEST_STATES.includes(r.state) || typeof r.instance !== 'string' || typeof r.action !== 'string') {
|
||
return json(res, 502, { error: 'invalid-response', message: 'the bus answered the request read in an unexpected shape' });
|
||
}
|
||
const refusal = r.done?.refusal ?? (r.cancelled ? 'request-cancelled' : null);
|
||
return json(res, 200, { ...r, ...(refusal ? { reason: reasonFor(refusal, r.done) } : {}) });
|
||
}
|
||
// Row 65 (mos-0840): the routing table. The Settings boundary and one bus call, `routes.set`,
|
||
// through the human transport. It is human-only and outside ORGANIZATION_KEYS, so the chief of
|
||
// staff's tool never reaches it. The bus checks the route rules; a refusal comes back with its code.
|
||
const ROUTE_KEYS = ['revision', 'routes'];
|
||
const ROUTE_REASONS = {
|
||
'harness-off': ['400', 'that harness isn\'t switched on in the business file\'s plugins.harness, which `mosaic plugins set` changes'],
|
||
'harness-refused': ['400', 'that harness module didn\'t load on the bus host, so its thinking levels are unknown'],
|
||
'thinking-unsupported': ['400', 'that harness doesn\'t apply that thinking level'],
|
||
'unknown-model': ['400', 'a model or fallback model isn\'t a key in the model table'],
|
||
'organization-partial-fallback': ['400', 'a fallback sets its harness, model and thinking together, or is cleared'],
|
||
'organization-unknown-instance': ['400', 'the business has no such instance'],
|
||
'organization-no-change': ['400', 'the business file already says that'],
|
||
'revision-conflict': ['409', 'the business file changed since you read it. Read the routes again'],
|
||
// D3 (P0-4): the model refusals name the next step. Status stays the bus status these codes
|
||
// already had (502). The Routing page adds Disable model proxy catalogue only when that switch
|
||
// can make the save apply (bus.js catalogueStep).
|
||
'model-catalogue-unavailable': ['502', 'the model catalogue is unavailable, so a model it supplies can\'t be checked. A change to thinking alone still saves; a model from the model proxy needs the proxy set up first'],
|
||
'model-provider-unavailable': ['502', 'the model provider doesn\'t offer that model now. A change to thinking alone still saves; otherwise pick a model the catalogue lists, or set up the model proxy when the model comes from it'],
|
||
'model-harness-incompatible': ['502', 'that model doesn\'t run on that harness. Pick a harness the model lists, or another model'],
|
||
'model-key-collision': ['502', 'that model key already names a different model in the model table. Pick the table\'s entry or another key'],
|
||
};
|
||
const routeObject = o => o && typeof o === 'object' && !Array.isArray(o);
|
||
const routesAnswer = v => /^[a-f0-9]{64}$/.test(v?.revision ?? '') && Array.isArray(v.harnesses) && Array.isArray(v.models) && routeObject(v.levels) && routeObject(v.instances);
|
||
// `note` (F3) adds the owner's change to the Console's action trail; best effort, after the log line.
|
||
async function routesSend(req, res, { routesWrite, preview, log, note = null }) {
|
||
// Instance names only; routes are never logged.
|
||
let names = [];
|
||
const line = outcome => { log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`); note?.({ action: 'routes.set', instances: names, outcome }); };
|
||
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
|
||
if (!preview.tester && !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', 'this needs the Console\'s own Origin');
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
|
||
let value;
|
||
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||
if (!routeObject(value) || Object.keys(value).some(key => !ROUTE_KEYS.includes(key)) || typeof value.revision !== 'string' || !/^[a-f0-9]{64}$/.test(value.revision) ||
|
||
!routeObject(value.routes) || Object.keys(value.routes).length === 0) {
|
||
return refuse(400, 'invalid-request', 'this takes a revision and routes by instance, nothing else');
|
||
}
|
||
names = Object.keys(value.routes);
|
||
let result;
|
||
try { result = await routesWrite({ revision: value.revision, routes: value.routes }); } catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
if (sent !== false) return refuse(503, 'outcome-unknown', 'the change may have been made; nothing was retried. Read the routes again before trying', 'unknown');
|
||
const [status, reason] = Object.hasOwn(ROUTE_REASONS, code) ? ROUTE_REASONS[code] : [String(busStatus(code)), 'refused by the bus'];
|
||
return refuse(Number(status), code, `${code}: ${reason}. Nothing changed.`);
|
||
}
|
||
if (!routesAnswer(result) || names.some(name => !result.instances[name])) {
|
||
return refuse(503, 'outcome-unknown', 'the bus answered in an unexpected shape; the change may have been made. Read the routes again before trying', 'unknown');
|
||
}
|
||
line('done');
|
||
return json(res, 200, { ...result, at: new Date().toISOString() });
|
||
}
|
||
// Row 148 (#1642): the spec and docs editor. Two reads and one save, all through the human transport.
|
||
// The save has the Settings boundary (header user, exact Origin, same-origin Fetch Metadata, JSON,
|
||
// strict keys) with a 1 MiB body on this route only; every other POST keeps its own smaller limit.
|
||
// The bus decides the path (spec/north-star.md and docs/*.md), the base commit and the author.
|
||
const FILE_SAVE_MAX = 1024 * 1024;
|
||
const FILE_SAVE_KEYS = ['project', 'path', 'base', 'content'];
|
||
const COMMIT = /^[0-9a-f]{40}$/;
|
||
const PATH_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,200}$/;
|
||
const quoted = value => JSON.stringify(typeof value === 'string' ? value.slice(0, 200) : null);
|
||
async function projectFileSend(req, res, { projectSave, preview, log }) {
|
||
let target = null;
|
||
// The project and path are quoted and cut short so a crafted value can't forge a log line. The content is never logged.
|
||
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
|
||
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
|
||
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', 'a save needs the Console\'s own Origin');
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
|
||
let value;
|
||
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
|
||
if (Object.keys(value).length !== FILE_SAVE_KEYS.length || !FILE_SAVE_KEYS.every(k => typeof value[k] === 'string')) {
|
||
return refuse(400, 'invalid-request', 'a save takes project, path, base and content, all text, nothing else');
|
||
}
|
||
target = value;
|
||
let result;
|
||
try { result = await projectSave({ project: value.project, path: value.path, base: value.base, content: value.content }); } catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
return sent === false
|
||
? refuse(busStatus(code), code, code === 'stale-base' ? 'not saved: the file changed since you opened it. Reload it, then make your edit again.' : `not saved: the bus refused it (${code}).`)
|
||
: refuse(503, 'outcome-unknown', 'the save may have been made; nothing was retried. Reload the file before saving again', 'unknown');
|
||
}
|
||
if (!COMMIT.test(result?.commit) || result.path !== value.path) return refuse(503, 'outcome-unknown', 'the bus answered without the new commit; the save may have been made. Reload the file before saving again', 'unknown');
|
||
line(`commit=${result.commit}`);
|
||
return json(res, 200, { path: result.path, commit: result.commit, at: new Date().toISOString() });
|
||
}
|
||
// GET only. The bus is asked for a file list, or for one file with its current commit; a result
|
||
// that is not the expected shape is an invalid response, never passed on.
|
||
async function projectFileRead(res, read, path, search) {
|
||
const query = new URLSearchParams(search), file = path === '/api/project-file';
|
||
const keys = file ? ['project', 'path'] : ['project'];
|
||
if ([...query.keys()].some(k => !keys.includes(k)) || keys.some(k => query.getAll(k).length !== 1 || !PATH_SHAPE.test(query.get(k))))
|
||
return json(res, 400, { error: 'invalid-request', message: file ? 'a file read takes a project and a path' : 'a file list takes a project' });
|
||
if (!read) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const result = await read(Object.fromEntries(keys.map(k => [k, query.get(k)])));
|
||
const valid = file
|
||
? 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');
|
||
// `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
|
||
// exists the bus refuses it, and the page shows that code.
|
||
const REVERT_KEYS = ['project', 'commit'];
|
||
async function libraryRevertSend(req, res, { libraryRevert, preview, log }) {
|
||
let target = null;
|
||
const line = outcome => log(`${new Date().toISOString()} library-revert user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} commit=${quoted(target?.commit)} ${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', 'a revert needs the Console\'s own Origin');
|
||
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a revert 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 === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== REVERT_KEYS.length ||!REVERT_KEYS.every(k => typeof value[k] === 'string') || !PATH_SHAPE.test(value.project) || !COMMIT.test(value.commit)) {
|
||
return refuse(400, 'invalid-request', 'a revert takes a project and a 40-hex commit, nothing else');
|
||
}
|
||
target = value;
|
||
let result;
|
||
try { result = await libraryRevert({ project: value.project, commit: value.commit }); } catch (err) {
|
||
const { code, sent } = sendOutcome(err);
|
||
return sent === false
|
||
? refuse(busStatus(code), code, `not reverted: the bus refused it (${code}).`)
|
||
: refuse(503, 'outcome-unknown', 'the revert may have been made; nothing was retried. Read the library status before trying again', 'unknown');
|
||
}
|
||
if (!COMMIT.test(result?.commit) || result.project !== value.project || result.reverted !== value.commit || typeof result.path !== 'string') return refuse(503, 'outcome-unknown', 'the bus answered without the new commit; the revert may have been made', 'unknown');
|
||
line(`commit=${result.commit}`);
|
||
return json(res, 200, { project: value.project, path: result.path, reverted: result.reverted, commit: result.commit, at: new Date().toISOString() });
|
||
}
|
||
async function libraryStatusRead(res, read, search) {
|
||
const query = new URLSearchParams(search);
|
||
if ([...query.keys()].some(k => k !== 'project') || query.getAll('project').length !== 1 || !PATH_SHAPE.test(query.get('project')))
|
||
return json(res, 400, { error: 'invalid-request', message: 'a library status read takes a project' });
|
||
if (!read) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||
try {
|
||
const result = await read({ project: query.get('project') });
|
||
if (result === null || typeof result !== 'object' || Array.isArray(result) || result.project !== query.get('project')) throw new BusReadError('invalid-response');
|
||
return json(res, 200, { ...result, 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 });
|
||
}
|
||
}
|
||
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with `sso`.
|
||
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
|
||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, proxyAdmin = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
|
||
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
|
||
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
|
||
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
|
||
if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port');
|
||
// The CLI also refuses SSO/dev-owner combinations and LAN flags without the
|
||
// dev owner. Keep this guard for callers that construct a session source here.
|
||
if ((sso?.mode === 'accounts') && (sso?.devOwner || devAllowHosts.length)) throw new Error('accounts mode refuses the dev LAN same-origin path');
|
||
if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner');
|
||
const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0;
|
||
if (sso?.devOwner && host !== '127.0.0.1' && !(devLan && isIP(host) === 4)) throw new Error(devLan ? 'dev local owner binds an IPv4 address' : 'dev local owner must bind 127.0.0.1');
|
||
if (!isLoopback(host) && !devLan) throw new Error('refusing to bind to non-loopback host');
|
||
if (host === 'localhost') host = '127.0.0.1';
|
||
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
|
||
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
|
||
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
|
||
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
|
||
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
|
||
if (tester && [send, settingsWrite, projectSave, libraryRevert, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
|
||
if (tester && !testerSocket) throw new Error('tester listener requires private socket');
|
||
if (!tester && testerSocket) throw new Error('tester socket requires tester listener');
|
||
// Row 252 (#1753, design section 4): Console decision answers need --sso and --send, never the tester.
|
||
if (decisionWrite !== null && (tester || sso === null || send === null || typeof decisionWrite !== 'function')) throw new Error('Console decision answers need --sso and --send on the main listener');
|
||
// #797 / #1839 Task 3: the request intake reads need --sso, the writes also --send; never the tester.
|
||
if ((requestRead !== null || requestWrite !== null) && (tester || sso === null || (requestRead !== null && typeof requestRead !== 'function') || (requestWrite !== null && (send === null || typeof requestWrite !== 'function'))))
|
||
throw new Error('Console requests need --sso (and --send for the writes) on the main listener');
|
||
// Phase P2 (#1873): the owner's System types writes need --sso and --send, never the tester.
|
||
if (systemTypesWrite !== null && (tester || sso === null || send === null || typeof systemTypesWrite !== 'function')) throw new Error('System types writes need --sso and --send on the main listener');
|
||
const confirmations = createConfirmations();
|
||
const registered = routeTable();
|
||
for (const registerRoutes of routes) registerRoutes(registered);
|
||
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
|
||
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
|
||
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart|stop)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
|
||
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
|
||
}
|
||
registered.add('POST', '/api/orgs', adminRoute);
|
||
// F3: the owner restarts the Console (console-restart-http.mjs). `restart` is set only under the
|
||
// supervisor; `boot` names this process so an open page can tell when the new one answers.
|
||
registered.add('POST', '/api/console/restart', (req, res, context) => consoleRestartHTTP(req, res, { ...context, restart, trail, boot, log }));
|
||
// U1 (#1873): one read across every organization the owner belongs to (workspace-read.mjs).
|
||
registered.add('GET', '/api/workspace', (req, res, context) => workspaceHTTP(req, res, { ...context, organizations, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
|
||
// #1873 A1: the agent levels behind the Agents grid. Read-only; owner only, like the workspace.
|
||
registered.add('GET', '/api/agents/levels', (req, res, context) => agentLevelsHTTP(req, res, { ...context, organizations, levels: agentLevels, log, ...(workspaceTimeoutMs === undefined ? {} : { timeoutMs: workspaceTimeoutMs }) }));
|
||
// U4b (#1873): the task board's writes, each to the one organization it names (workspace-tasks.mjs).
|
||
registered.add('POST', TASK_WRITE_ROUTE, (req, res, context) => workspaceTasksHTTP(req, res, { ...context, organizations, trail, log }));
|
||
const upstream = boardURL(board);
|
||
// `userOptions` (FED R6, #1800) is `session => options`, the bus-backed options over that session
|
||
// user's own transport (console-bus.mjs). Only a signed-in listener has user sessions.
|
||
if (userOptions !== null && (sso === null || typeof userOptions !== 'function')) throw new Error('user sessions need --sso');
|
||
const owner = { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files };
|
||
const hub = federationView(federation);
|
||
// An object with none of the three is refused too: asking for the preview never serves without it.
|
||
if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) {
|
||
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
|
||
}
|
||
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
|
||
if (send !== null && (sso === null || typeof send !== 'function')) throw new Error('--send needs --sso; header preview is read-only');
|
||
// The bound address and port, kept after close(): server.address() is null once the listener
|
||
// stops, and a request already on a kept-alive connection must still be answered (P19 #865).
|
||
// The tester listener is a Unix socket, so its address is a path and it has no port.
|
||
let boundAddress = null, boundPort = null;
|
||
const server = createServer(async (req, res) => {
|
||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null;
|
||
res.setHeader('x-content-type-options', 'nosniff');
|
||
res.setHeader('referrer-policy', 'no-referrer');
|
||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
|
||
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
|
||
// Presence, even with an empty value: never turn the public listener into a tester ingress.
|
||
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
|
||
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
|
||
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
|
||
if (tester && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-')))) return json(res, 403, { error: 'tester listener takes no sign-in' });
|
||
let path, search, orgId = null, orgList = false, currentSession = null, hook = null, originalPath, orgBusHealthy = true;
|
||
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
|
||
// never legitimate, so it is refused before any check reads the Host.
|
||
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, sso?.devOwner ? 403 : 400, { error: 'repeated Host refused' });
|
||
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
|
||
const loopHost = sso?.devOwner && [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`].includes(req.headers.host);
|
||
const lanHost = devLan && !loopHost && devAllowHosts.some(name => `${name}:${boundPort}` === req.headers.host);
|
||
const devHost = loopHost || lanHost;
|
||
if (sso?.devOwner && !devHost) return json(res, 403, { error: 'non-local Host refused' });
|
||
const localOrigin = devHost ? `http://${req.headers.host}` : null;
|
||
if (devHost && req.method !== 'GET' && req.method !== 'HEAD' && (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== localOrigin)) return json(res, 403, { error: 'cross-origin request refused' });
|
||
// Chromium sends no Sec-Fetch-* to an http origin that is not loopback (not potentially
|
||
// trustworthy), so a LAN page's writes arrive with the exact Origin checked above and no Fetch
|
||
// Metadata. Only there, and only when the header is absent, the write counts as same-origin for
|
||
// the route checks below. A Sec-Fetch-Site the browser did send is left for them to judge.
|
||
if (lanHost && req.method !== 'GET' && req.method !== 'HEAD' && !req.headersDistinct['sec-fetch-site']) {
|
||
req.headers['sec-fetch-site'] = 'same-origin';
|
||
req.headersDistinct['sec-fetch-site'] = ['same-origin'];
|
||
}
|
||
const named = devHost || (preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost);
|
||
// The proof precedes every auth-header read, including the wrong-Host fence.
|
||
if (proof !== null) {
|
||
const values = req.headersDistinct[proof.header];
|
||
if (!named && values) return json(res, 403, { error: 'proxy proof Host refused' });
|
||
if (named) {
|
||
const value = values?.length === 1 ? Buffer.from(values[0], 'utf8') : null;
|
||
if (!value || value.length !== proof.value.length || !timingSafeEqual(value, proof.value)) {
|
||
return json(res, 401, { error: 'proxy proof required' });
|
||
}
|
||
}
|
||
}
|
||
// HEADER with any other Host comes from a proxy that rewrote Host (nginx's default) and would
|
||
// otherwise land on the loopback path unchecked. The operator's browser never sends it.
|
||
if (preview !== null && !named && req.headersDistinct[preview.header]) return json(res, 403, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' });
|
||
try {
|
||
if (tester) {
|
||
if (req.headers.host !== new URL(TESTER_ORIGIN).host) return json(res, 403, { error: 'non-local Host refused' });
|
||
} else if (!named) {
|
||
const authority = new URL(`http://${req.headers.host}`);
|
||
if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== boundPort) return json(res, 403, { error: 'non-local Host refused' });
|
||
}
|
||
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
|
||
} catch { return json(res, 400, { error: 'invalid URL' }); }
|
||
originalPath = path;
|
||
hook = registered.find(req.method, originalPath);
|
||
if (organizations && path.startsWith('/api/')) {
|
||
orgList = path === '/api/orgs';
|
||
const match = /^\/api\/orgs\/([^/]+)\/(.+)$/.exec(path);
|
||
if (match) {
|
||
orgId = match[1];
|
||
path = `/api/${match[2]}`;
|
||
} else if (!orgList && path !== '/api/config' && !hook) {
|
||
return json(res, 404, { error: 'org-unavailable' });
|
||
}
|
||
}
|
||
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
|
||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path);
|
||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||
let signer = preview, ssoSession = null;
|
||
if (sso !== null && named) {
|
||
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
|
||
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
|
||
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
|
||
currentSession = session;
|
||
if (!session && path !== '/healthz') {
|
||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||
if (requestPath) return requestRefuse(res, 'sign-in-required');
|
||
if (remotePath || systemTypesPath || autonomyPath) return json(res, 401, { error: 'sign-in-required' });
|
||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||
return res.end();
|
||
}
|
||
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: localOrigin ?? sso.publicOrigin };
|
||
// Row 252: only the owner's session answers a decision. A granted user stops at userRoute below.
|
||
if (session?.rootOwner === true) ssoSession = session;
|
||
// FED R6 (#1800): a granted user. The Console's gate first; behind it no human-transport
|
||
// object is left, so a route past the gate reaches the broker only as this user.
|
||
const instanceAction = originalPath === '/api/console/restart';
|
||
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
|
||
if (session && !session.rootOwner && session.user === undefined) {
|
||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = upstream = null;
|
||
}
|
||
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
|
||
// #1873: proxy setup answers a grant holder the way the bus answers an agent.
|
||
if (path.startsWith('/api/proxy/admin/')) return json(res, 403, { error: 'human-required' });
|
||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||
reads = null; upstream = null; libraryStatus = null; libraryRevert = null; files = USER_FILES; granted = true; proxyCall = null; systemTypesWrite = null;
|
||
chatGrants = sso.chatGrants?.(session) ?? [];
|
||
}
|
||
}
|
||
if (named && preview !== null) {
|
||
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
|
||
const values = req.headersDistinct[preview.header];
|
||
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
|
||
}
|
||
if (organizations && (orgId !== null || orgList)) {
|
||
if (!currentSession) return json(res, 404, { error: 'org-unavailable' });
|
||
if (orgList && !hook) {
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||
try { return json(res, 200, await organizations.list(currentSession)); }
|
||
// A fixed message: the registry is read before any per-org authority is known.
|
||
catch { return json(res, 503, { error: 'org-unavailable', message: 'Organization registry could not be read' }); }
|
||
}
|
||
if (orgList && hook) orgList = false;
|
||
let selected;
|
||
// Administration hooks authorize and resolve their own organization, including archived entries.
|
||
const orgAdminHook = hook?.handler === adminRoute;
|
||
if (orgId !== null && !orgAdminHook) {
|
||
try { selected = await organizations.resolve(orgId, currentSession); }
|
||
catch (error) { if (error?.code === 'org-archived') return json(res, 409, { error: 'org-archived', message: 'organization is archived' }); return json(res, 404, { error: 'org-unavailable' }); }
|
||
if (!selected) return json(res, 404, { error: 'org-unavailable' });
|
||
orgBusHealthy = selected.busHealthy !== false;
|
||
if (granted && !hook && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only' });
|
||
// Never inherit a sibling's transport when a selected organization is offline.
|
||
({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null,
|
||
projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null,
|
||
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
|
||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, upstream = null } = selected);
|
||
}
|
||
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
|
||
// agents, assistant, instances, model proxy). The routes below keep their own checks.
|
||
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))
|
||
&& !(orgId !== null && !tester && !granted && named && ORG_OWNER_WRITE(path)))
|
||
return json(res, 403, { error: 'unavailable', sent: false });
|
||
}
|
||
if (hook) {
|
||
const instanceAction = originalPath === '/api/console/restart';
|
||
if (!hook.publicRead && (!currentSession || !named || (granted && req.method !== 'GET' && !instanceAction))) return json(res, 403, { error: 'owner-only' });
|
||
const origin = localOrigin ?? sso?.publicOrigin;
|
||
if (req.method !== 'GET' && one(req, 'origin') !== origin) return json(res, 403, { error: 'cross-origin request refused' });
|
||
if (req.method !== 'GET' && one(req, 'sec-fetch-site') !== 'same-origin') return json(res, 403, { error: 'cross-site request refused' });
|
||
return hook.handler(req, res, { path: originalPath, search, session: currentSession, orgId, origin, owner: !tester && !granted && named && currentSession?.rootOwner === true, operator: !tester && named && currentSession?.instanceRole === 'operator', send: owner.send !== null });
|
||
}
|
||
const writer = tester ? { tester: true, user: 'tester', origin: TESTER_ORIGIN } : signer;
|
||
// F3: the owner's routing and agent changes go to the action trail too (who, when, what).
|
||
const ownerNote = !tester && !granted && ssoSession && trail ? entry => {
|
||
try { trail({ by: actor(ssoSession), ...(orgId !== null ? { org: orgId } : {}), ...entry }); }
|
||
catch { log(`${new Date().toISOString()} trail write failed for ${entry.action}`); }
|
||
} : null;
|
||
// P4 page and verbs are owner-only, including reads and static page assets.
|
||
// This runs after owner sign-in and the granted-user fence, before generic
|
||
// tester writes or static-file serving. No localhost preview grants access.
|
||
if (path === '/proxy' || path === '/settings/model-proxy' || path === '/proxy.js' || path.startsWith('/api/proxy/')) {
|
||
if (tester || granted || !named) return json(res, 403, { error: 'human-required' });
|
||
if ((path === '/proxy' || path === '/settings/model-proxy') && (req.method === 'GET' || req.method === 'HEAD')) {
|
||
res.writeHead(303, { location: '/#/settings/model-proxy', 'cache-control': 'no-store' });
|
||
return res.end();
|
||
}
|
||
// #1873: proxy setup. Instance-wide host actions (fetch, unit) and the init and fence verbs on
|
||
// the selected bus; only the instance owner's own session reaches them, never an org owner.
|
||
if (path.startsWith('/api/proxy/admin/')) return proxyAdminHTTP(req, res, { path, admin: proxyAdmin, call: proxyCall, named, tester, granted,
|
||
owner: !tester && !granted && named && sso !== null && currentSession?.rootOwner === true, send: owner.send !== null,
|
||
origin: sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`, user: signer?.user, log });
|
||
if (path.startsWith('/api/proxy/')) return proxyHTTP(req, res, { path, call: proxyCall, named, tester, granted,
|
||
send, origin: sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`, user: signer?.user, log });
|
||
}
|
||
|
||
// Row 252: Approve and Deny. The tester listener never offers them. Anywhere else without a signed-in
|
||
// session on the public name under --sso and --send, they are read-only.
|
||
if (decisionPath) {
|
||
if (tester) return decisionRefuse(res, 'not-found');
|
||
const decide = decisionWrite && send && named && sso ? { call: decisionWrite, origin: localOrigin ?? sso.publicOrigin } : null;
|
||
return decisionHTTP(req, res, { path, decide, session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, confirmations, log });
|
||
}
|
||
// #797 / #1839 Task 3: the owner's request intake controls. The tester listener never has them, and a
|
||
// granted user stopped at userRoute above. Reads need the read transport; the writes also --send.
|
||
if (requestPath) {
|
||
if (tester) return requestRefuse(res, 'not-found');
|
||
// Without SSO there is no owner session to act as, and preview mode has none either. Refuse here, before any
|
||
// SSO field is read: requestHTTP answers a missing transport itself and touches no session, live check or origin.
|
||
if (!(named && sso !== null))
|
||
return requestHTTP(req, res, { path, requestRead: null, requestWrite: null, session: null, live: () => null, origin: null, log });
|
||
return requestHTTP(req, res, { path, requestRead, requestWrite: send ? requestWrite : null,
|
||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin, log });
|
||
}
|
||
if (remotePath) {
|
||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||
return projectRemoteHTTP(req, res, { path, read: requestRead, write: send ? requestWrite : null,
|
||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
|
||
}
|
||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||
if(autonomyPath) {
|
||
if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'});
|
||
// D1 (#1873): an organization's own autonomy writer (`autonomyWrite`, the four autonomy verbs only);
|
||
// without one the route keeps the request writer, as before.
|
||
return autonomyHTTP(req,res,{path,read:requestRead,write:send?(autonomyWrite??requestWrite):null,session:ssoSession,
|
||
live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin});
|
||
}
|
||
if (systemTypesPath) {
|
||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
|
||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
|
||
}
|
||
if (tester && ['/project-remote.js', '/project-remote.css', '/files.js', '/files.css', '/system-types.js', '/system-types.css'].includes(path)) return json(res, 403, { error: 'owner-only' });
|
||
// Row 194 (#1692, D4.1): the tester also launches and terminates.
|
||
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name', '/api/agent/launch', '/api/agent/terminate'].includes(path) && !TESTER_CHAT_WRITE.test(path))
|
||
return json(res, 403, { error: 'read-only', sent: false });
|
||
if (path === '/api/processing' || path.startsWith('/api/processing/'))
|
||
return processingHTTP(req, res, { path, search, processing, named, send, preview: signer });
|
||
if(path==='/api/settings'&&req.method==='POST'){
|
||
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
|
||
return settingsSend(req,res,{settingsWrite,preview:signer,log});
|
||
}
|
||
if (Object.hasOwn(SEAT_ROUTES, path)) {
|
||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||
const write = path === '/api/name' ? nameWrite : assistantStop;
|
||
if ((!tester && (!send || !named)) || !write) return json(res, 403, { error: 'read-only', sent: false });
|
||
return seatSend(req, res, { route: SEAT_ROUTES[path], write, preview: writer, log });
|
||
}
|
||
if (path === '/api/instances') {
|
||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||
if ((!tester && (!send || !named)) || !instancesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||
return instanceSend(req, res, { instancesWrite, preview: writer, log, note: ownerNote });
|
||
}
|
||
// Row 194 (#1692, D7): the Agents section. No route gets a --send exception.
|
||
if (Object.hasOwn(AGENT_ROUTES, path)) {
|
||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||
if ((!tester && (!send || !named)) || (tester && !AGENT_ROUTES[path].tester) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||
return agentSend(req, res, { route: AGENT_ROUTES[path], agentWrite, preview: writer, log, note: ownerNote });
|
||
}
|
||
if (path === '/api/agent/request') {
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||
if ((!tester && (!send || !named)) || !agentWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||
return agentRequest(res, { agentWrite, search });
|
||
}
|
||
if (path === '/api/routes') {
|
||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||
if ((!tester && (!send || !named)) || !routesWrite) return json(res, 403, { error: 'read-only', sent: false });
|
||
return routesSend(req, res, { routesWrite, preview: writer, log, note: ownerNote });
|
||
}
|
||
if (path === '/api/library/revert') {
|
||
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
|
||
if (!send || !libraryRevert || !named) return json(res, 403, { error: 'read-only', sent: false });
|
||
return libraryRevertSend(req, res, { libraryRevert, preview: signer, log });
|
||
}
|
||
if (path === '/api/project-file' && req.method === 'POST') {
|
||
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' });
|
||
return busSend(req, res, { send, preview: signer, log });
|
||
}
|
||
// Row 219: labels only, from this listener's own human or tester bus, after auth above.
|
||
if (path === '/api/chat/instances') {
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' });
|
||
if (search) return json(res, 400, { error: 'invalid-request' });
|
||
if (!bus?.instances) return json(res, 503, { error: 'not-configured' });
|
||
try {
|
||
const value = await bus.instances();
|
||
if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances))
|
||
throw new BusReadError('invalid-response');
|
||
const rows = Object.entries(value.instances)
|
||
.filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition))
|
||
.map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance }));
|
||
if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response');
|
||
return json(res, 200, { rows, at: new Date().toISOString() });
|
||
} catch (err) {
|
||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||
return json(res, busStatus(code), { error: code });
|
||
}
|
||
}
|
||
// #797 MVP3: the project archive routes (project-archive-http.mjs), ahead of chatHTTP, whose
|
||
// /api/chat/projects route they extend. A tester POST is already stopped by the fence above.
|
||
if (path === '/api/chat/projects/archived' || PROJECT_ARCHIVE_ROUTE.test(path))
|
||
return projectArchiveHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
|
||
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
|
||
}
|
||
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
|
||
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
|
||
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
|
||
const conversation = path === '/api/conversations' || path === '/api/conversation';
|
||
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
|
||
if (allowed) {
|
||
if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' });
|
||
if (upstream === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no board for this session' });
|
||
let bytes;
|
||
if (allowed === 'POST') {
|
||
try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); }
|
||
}
|
||
try {
|
||
const response = await fetch(upstream + path + (conversation ? search : ''), {
|
||
method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes,
|
||
redirect: 'error', signal: AbortSignal.timeout(timeout),
|
||
});
|
||
const text = await response.text();
|
||
JSON.parse(text); // Never serve upstream HTML or scripts as API data.
|
||
res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
||
return res.end(text);
|
||
} catch {
|
||
return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream });
|
||
}
|
||
}
|
||
if (path.startsWith('/api/bus/')) {
|
||
const verb = path.slice('/api/bus/'.length);
|
||
if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' });
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||
// Owner/reader metadata is never a tester read, even with a supplied bus.
|
||
if (tester && verb === 'thread.claims') return json(res, 403, { error: 'tester-refused' });
|
||
return busRead(res, bus, verb, search);
|
||
}
|
||
if (path === '/api/task-fields') {
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||
return consoleRead(res, () => fieldTable(fieldsFile));
|
||
}
|
||
if (path === '/api/library/status') {
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||
return libraryStatusRead(res, libraryStatus, search);
|
||
}
|
||
if (path === '/api/project-files' || path === '/api/project-file') {
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||
return projectFileRead(res, path === '/api/project-file' ? projectFile : projectFiles, path, search);
|
||
}
|
||
if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings' || path === '/api/bus-status') {
|
||
if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' });
|
||
const a = boundAddress, address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`;
|
||
if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address }));
|
||
if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' });
|
||
if (path === '/api/business') return consoleRead(res, () => reads.business());
|
||
if (path === '/api/bus-status') return consoleRead(res, () => reads.busStatus());
|
||
if (path === '/api/queue') return consoleRead(res, () => reads.queue());
|
||
const id = path.slice('/api/queue/'.length);
|
||
if (!QUEUE_ID.test(id)) return json(res, 400, { error: 'invalid-request', message: 'a queue row id is a positive integer' });
|
||
return consoleRead(res, () => reads.row(Number(id)));
|
||
}
|
||
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
|
||
// `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 } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, 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);
|
||
if (!file) return json(res, 404, { error: 'not found' });
|
||
try {
|
||
let content = readFileSync(resolve(root, file[0]));
|
||
if (organizations && path === '/') content = Buffer.from(content.toString('utf8').replace('<html lang="en"', '<html data-organizations lang="en"'));
|
||
res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-store' });
|
||
res.end(req.method === 'HEAD' ? undefined : content);
|
||
} catch { json(res, 500, { error: 'WebUI asset unavailable' }); }
|
||
});
|
||
server.on('listening', () => { boundAddress = server.address(); boundPort = boundAddress && typeof boundAddress === 'object' ? boundAddress.port : null; });
|
||
if (tester) return bindTesterSocket(server, testerSocket);
|
||
return new Promise((resolvePromise, reject) => {
|
||
server.once('error', reject);
|
||
server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); });
|
||
});
|
||
}
|