153 lines
12 KiB
JavaScript
153 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
// MOSAIC_TESTER_SOCKET_V1: private tester ingress, no tester TCP.
|
|
import { createChat } from './chat.mjs';
|
|
import { startServer, previewOptions, proofOptions, DEFAULT_BOARD } from './serve.mjs';
|
|
import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
|
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
|
import { consoleAccess } from './access.mjs';
|
|
import { accessContextFor } from '../../cli/src/access.mjs';
|
|
import { accessFilePath, loadAccess } from '../../business/src/access.mjs';
|
|
import { startTesterSocket } from './tester-socket.mjs';
|
|
import { TESTER_CLI } from '../../cli/src/transport.mjs';
|
|
import { views } from '../../bus/src/views.mjs';
|
|
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
|
|
import { configDir } from '../../business/src/business.mjs';
|
|
import { consoleReads, redactor } from './reads.mjs';
|
|
import { createDevOwner } from './dev-owner.mjs';
|
|
import { devOwnerInstance } from '../../cli/src/dev-local-owner.mjs';
|
|
import { createSso, readSlot } from './sso.mjs';
|
|
import { statSync } from 'node:fs';
|
|
import { isAbsolute } from 'node:path';
|
|
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
|
|
const args = process.argv.slice(2);
|
|
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
|
try {
|
|
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
|
if (args.shift() !== 'serve') throw new Error(usage);
|
|
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
|
|
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false;
|
|
while (args.length) {
|
|
const flag = args.shift();
|
|
// Row 76 (#1570): the one flag without a value.
|
|
if (flag === '--send') { sendOn = true; continue; }
|
|
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
|
|
if (flag === '--dev-local-owner') { devOwner = true; continue; }
|
|
if (flag === '--sso') { ssoOn = true; continue; }
|
|
if (flag === '--tester-socket') { testerSocket = true; continue; }
|
|
if (flag === '--tester-port') throw new Error('--tester-port retired; activate explicitly with --tester-socket');
|
|
const value = args.shift();
|
|
if (!value) throw new Error(`missing value for ${flag}`);
|
|
if (flag === '--port') {
|
|
if (!/^\d+$/.test(value) || Number(value) > 65535) throw new Error('port must be 0..65535');
|
|
options.port = Number(value);
|
|
} else if (flag === '--board') options.board = value;
|
|
// Row 77 (#1571): the canonical checkout the queue read runs from, when
|
|
// serve runs from a pinned copy; everything else loads from the copy.
|
|
else if (flag === '--checkout') {
|
|
let dir = false;
|
|
try { dir = isAbsolute(value) && statSync(value).isDirectory(); } catch { /* refused below */ }
|
|
if (!dir) throw new Error('--checkout must be the absolute path of a directory');
|
|
options.checkout = value;
|
|
}
|
|
else if (flag === '--business') {
|
|
if (!ID_PATTERN.test(value)) throw new Error('business must be a business id');
|
|
options.business = value;
|
|
}
|
|
// Row 74 (#1568): the preview mode, all three or none, checked before any config is read.
|
|
else if (flag === '--public-host') preview.publicHost = value;
|
|
else if (flag === '--auth-header') preview.authHeader = value;
|
|
else if (flag === '--allow-user') preview.allowUser = value;
|
|
else if (flag === '--proof-header') proofFlags.proofHeader = value;
|
|
else if (flag === '--proof-file') proofFlags.proofFile = value;
|
|
else throw new Error(`unknown option: ${flag}`);
|
|
}
|
|
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
|
|
const instance = devOwner ? devOwnerInstance() : null;
|
|
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
|
|
options.preview = previewOptions(preview);
|
|
if (sendOn && !ssoOn && !devOwner) throw new Error('--send needs --sso; header preview is read-only');
|
|
const proof = proofOptions(proofFlags, options.preview);
|
|
// The bus views (slice 1 S5) need the system config. Without it the
|
|
// board still serves and the bus routes answer not-configured.
|
|
// The Queue, Business and Settings reads (row 54) need no bus; without
|
|
// the system config, Business can't resolve authority and Settings has
|
|
// no notifier. A refusal printed here names no path (redactor).
|
|
// --send without a bus refuses to start rather than serve a composer that can't send.
|
|
let bus = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, libraryFile = null, libraryStatus = null, librarySync = null, libraryRevert = null, librarySave = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, decisionWrite = null, requestRead = null, requestWrite = null, busLine, system = null;
|
|
let processing = null, ownerCall = null;
|
|
try {
|
|
system = loadSystem({ instance });
|
|
const { dataRoot } = system;
|
|
const call = ownerCall = busCaller({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
|
// FED R6 (#1800): the bus-backed options, shared with each granted user's session (console-bus.mjs).
|
|
({ processing, chat, bus, projectFiles, projectFile, libraryFile, libraryStatus, send, librarySync, libraryRevert, librarySave, settingsWrite, projectSave, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall } = consoleBus(call, { send: sendOn }));
|
|
// Row 252 (#1753): Approve and Deny on grant requests, through the console wrapper, only with
|
|
// --sso and --send.
|
|
if ((ssoOn || devOwner) && sendOn) decisionWrite = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
|
// #797 / #1839 Task 3: the request intake controls use the same console wrapper (the signed-in
|
|
// (provider, iss, sub) is the session's, never the client's). Reads need --sso, writes --sso and
|
|
// --send. Workspace reads and project remote operations use this owner-only, verb-fenced transport.
|
|
if (ssoOn || devOwner) requestRead = consoleCall({ dataRoot, socket: socketPath(dataRoot), business: options.business ?? null });
|
|
if ((ssoOn || devOwner) && sendOn) requestWrite = requestRead;
|
|
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
|
|
} catch (err) {
|
|
const why = redactor({ configDir: configDir() })(err.message);
|
|
if (ssoOn || devOwner) throw new Error(`${devOwner ? "--dev-local-owner" : "--sso"} needs the system config (${why})`);
|
|
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
|
|
if (testerSocket) throw new Error(`--tester-socket needs the bus configured (${why})`);
|
|
busLine = `Bus: not configured (${why})`;
|
|
}
|
|
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
|
|
// The sign-in config is checked again here, then each slot file, before anything listens. A
|
|
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
|
|
let sso = devOwner ? createDevOwner() : null, userOptions = null;
|
|
if (ssoOn) {
|
|
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
|
|
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
|
|
// FED R6 (#1800): access.json users sign in too. R1's loader reads the file on every check,
|
|
// with the same context as `mosaic access`; a missing or refused file signs nobody in.
|
|
const users = consoleAccess({
|
|
load: () => loadAccess(accessFilePath(configDir()), accessContextFor(system, process.env)),
|
|
business: busBusiness({ dataRoot: system.dataRoot, business: options.business ?? null }),
|
|
});
|
|
sso = await createSso(system.console.sso, { secrets, users });
|
|
// Each request of a granted user's session gets the bus options over that user's own
|
|
// user-cli transport. The human transport above serves only the chat's `plugins` read (C6).
|
|
userOptions = userOptionsFor({ owner: ownerCall, send: sendOn, transport: user => userCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, user }) });
|
|
}
|
|
let server;
|
|
try {
|
|
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null });
|
|
} catch (err) { sso?.close(); throw err; }
|
|
let testerServer = null;
|
|
if (testerSocket) {
|
|
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
|
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
|
|
try {
|
|
testerServer = await startTesterSocket({ dataRoot: system.dataRoot, board: options.board,
|
|
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
|
|
processing: { read: () => call('processing.status') },
|
|
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
|
|
libraryFile: args => call('library.file', args), libraryStatus: args => call('library.status', args),
|
|
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
|
|
// Row 194 (#1692, D4.1): the tester launches, terminates and reads its own requests.
|
|
agentWrite: agentCaller(call, TESTER_AGENT_VERBS),
|
|
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
|
|
chat: createChat({ call }),
|
|
});
|
|
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
|
|
}
|
|
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, read-only` : '';
|
|
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
|
|
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
|
|
const health = sso?.health() ?? {};
|
|
const ssoLines = sso && !devOwner ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
|
|
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
|
|
if (devOwner) console.log(`DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner (named instance ${instance})`);
|
|
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
|
|
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
|
|
} catch (err) {
|
|
console.error(`refused: ${err.message}`);
|
|
process.exitCode = 2;
|
|
}
|