#!/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 { organizationConsole } from './organizations.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, orgPaths, REPO, rolesDir } from '../../cli/src/config.mjs'; import { busStatus as orgBusStatus } from '../../cli/src/org-bus.mjs'; import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs'; import { showOrg } from '../../business/src/org-service.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; // The local-development console command has no separate --send flag. if (devOwner) sendOn = true; 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 }) }); } const organizations = devOwner ? organizationConsole({ ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance }, listOrgs, showOrg, resolveOrg, orgPaths, busStatus: orgBusStatus, options: ({ id, root, paths, session }) => { const owner = busCaller({ dataRoot: root, socket: paths.socket, business: id }); const call = session.user === undefined ? owner : userCaller({ dataRoot: root, socket: paths.socket, business: id, user: session.user }); return { ...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }), decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null, reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }), }; }, }) : null; 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, organizations, preselect: options.business ?? 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; }