/** * `mosaic federation` command group — federation grant + peer management (FED-M2-08). * * All HTTP calls go to the local gateway admin API using an admin token * resolved from CLI options or meta.json. * * Subcommands: * grant create --peer-id --user-id --scope [--expires-at ] * grant list [--peer-id ] [--user-id ] [--status pending|active|revoked|expired] * grant show * grant revoke [--reason ] * grant token [--ttl 900] * * peer list * peer add */ import type { Command } from 'commander'; import { readMeta } from './gateway/daemon.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface FedParentOpts { host: string; port: string; token?: string; json?: boolean; } interface ResolvedOpts { baseUrl: string; token?: string; json: boolean; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function resolveOpts(raw: FedParentOpts): ResolvedOpts { const meta = readMeta(); const host = raw.host ?? meta?.host ?? 'localhost'; const port = parseInt(raw.port, 10) || meta?.port || 14242; const token = raw.token ?? meta?.adminToken; return { baseUrl: `http://${host}:${port.toString()}`, token, json: raw.json ?? false, }; } function requireToken(opts: ResolvedOpts): string { if (!opts.token) { console.error( 'Error: admin token required. Use -t/--token or ensure meta.json has adminToken.', ); process.exit(1); } return opts.token; } async function apiRequest( opts: ResolvedOpts, method: string, path: string, body?: unknown, ): Promise { const token = requireToken(opts); const url = `${opts.baseUrl}${path}`; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: body !== undefined ? JSON.stringify(body) : undefined, }); const text = await res.text(); if (!res.ok) { let message = text; try { const parsed = JSON.parse(text) as { message?: string }; message = parsed.message ?? text; } catch { // use raw text } throw new Error(`HTTP ${res.status.toString()}: ${message}`); } if (!text) return undefined as unknown as T; return JSON.parse(text) as T; } function printJson(data: unknown, useJson: boolean): void { if (useJson) { console.log(JSON.stringify(data, null, 2)); } } function printTable(rows: Record[]): void { if (rows.length === 0) { console.log('(none)'); return; } for (const row of rows) { for (const [key, val] of Object.entries(row)) { console.log(` ${key}: ${String(val ?? '')}`); } console.log(''); } } // --------------------------------------------------------------------------- // Command registration // --------------------------------------------------------------------------- export function registerFederationCommand(program: Command): void { const fed = program .command('federation') .alias('fed') .description('Manage federation grants and peers') .option('-h, --host ', 'Gateway host', 'localhost') .option('-p, --port ', 'Gateway port', '14242') .option('-t, --token ', 'Admin token') .option('--json', 'Machine-readable JSON output') .action(() => fed.outputHelp()); // ─── grant subcommands ───────────────────────────────────────────────── const grant = fed .command('grant') .description('Manage federation grants') .action(() => grant.outputHelp()); grant .command('create') .description('Create a new federation grant') .requiredOption('--peer-id ', 'Peer UUID') .requiredOption('--user-id ', 'Subject user UUID') .requiredOption('--scope ', 'Grant scope as JSON string') .option('--expires-at ', 'Optional expiry (ISO 8601)') .action( async (cmdOpts: { peerId: string; userId: string; scope: string; expiresAt?: string }) => { const opts = resolveOpts(fed.opts() as FedParentOpts); try { let scope: Record; try { scope = JSON.parse(cmdOpts.scope) as Record; } catch { console.error('Error: --scope must be valid JSON'); process.exit(1); } const body: Record = { peerId: cmdOpts.peerId, subjectUserId: cmdOpts.userId, scope, }; if (cmdOpts.expiresAt) body['expiresAt'] = cmdOpts.expiresAt; const result = await apiRequest>( opts, 'POST', '/api/admin/federation/grants', body, ); if (opts.json) { printJson(result, true); } else { console.log(`Grant created: ${String(result['id'])}`); console.log(` Peer: ${String(result['peerId'])}`); console.log(` User: ${String(result['subjectUserId'])}`); console.log(` Status: ${String(result['status'])}`); } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }, ); grant .command('list') .description('List federation grants') .option('--peer-id ', 'Filter by peer UUID') .option('--user-id ', 'Filter by subject user UUID') .option('--status ', 'Filter by status (pending|active|revoked|expired)') .action(async (cmdOpts: { peerId?: string; userId?: string; status?: string }) => { const opts = resolveOpts(fed.opts() as FedParentOpts); try { const params = new URLSearchParams(); if (cmdOpts.peerId) params.set('peerId', cmdOpts.peerId); if (cmdOpts.userId) params.set('subjectUserId', cmdOpts.userId); if (cmdOpts.status) params.set('status', cmdOpts.status); const qs = params.toString() ? `?${params.toString()}` : ''; const result = await apiRequest[]>( opts, 'GET', `/api/admin/federation/grants${qs}`, ); if (opts.json) { printJson(result, true); } else { console.log(`Grants (${result.length.toString()}):\n`); printTable(result); } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); grant .command('show ') .description('Get a single grant by ID') .action(async (id: string) => { const opts = resolveOpts(fed.opts() as FedParentOpts); try { const result = await apiRequest>( opts, 'GET', `/api/admin/federation/grants/${id}`, ); if (opts.json) { printJson(result, true); } else { for (const [key, val] of Object.entries(result)) { console.log(` ${key}: ${String(val ?? '')}`); } } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); grant .command('revoke ') .description('Revoke an active grant') .option('--reason ', 'Revocation reason') .action(async (id: string, cmdOpts: { reason?: string }) => { const opts = resolveOpts(fed.opts() as FedParentOpts); try { const body: Record = {}; if (cmdOpts.reason) body['reason'] = cmdOpts.reason; const result = await apiRequest>( opts, 'PATCH', `/api/admin/federation/grants/${id}/revoke`, body, ); if (opts.json) { printJson(result, true); } else { console.log(`Grant ${id} revoked.`); if (result['revokedReason']) console.log(` Reason: ${String(result['revokedReason'])}`); } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); grant .command('token ') .description('Generate a single-use enrollment token for a grant') .option('--ttl ', 'Token lifetime in seconds (60-900)', '900') .action(async (id: string, cmdOpts: { ttl: string }) => { const opts = resolveOpts(fed.opts() as FedParentOpts); try { const ttlSeconds = parseInt(cmdOpts.ttl, 10) || 900; const result = await apiRequest<{ token: string; expiresAt: string; enrollmentUrl: string; }>(opts, 'POST', `/api/admin/federation/grants/${id}/tokens`, { ttlSeconds }); if (opts.json) { printJson(result, true); } else { console.log('Enrollment token generated:'); console.log(` Token: ${result.token}`); console.log(` Expires at: ${result.expiresAt}`); console.log(` Enrollment URL: ${result.enrollmentUrl}`); console.log(''); console.log('Share the enrollment URL with the remote peer operator.'); } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); // ─── peer subcommands ────────────────────────────────────────────────── const peer = fed .command('peer') .description('Manage federation peers') .action(() => peer.outputHelp()); peer .command('list') .description('List all federation peers') .action(async () => { const opts = resolveOpts(fed.opts() as FedParentOpts); try { const result = await apiRequest[]>( opts, 'GET', '/api/admin/federation/peers', ); if (opts.json) { printJson(result, true); } else { console.log(`Peers (${result.length.toString()}):\n`); printTable(result); } } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); peer .command('add ') .description('Enroll as a peer using a remote enrollment URL') .action(async (enrollmentUrl: string) => { const opts = resolveOpts(fed.opts() as FedParentOpts); try { // 1. Validate enrollment URL let parsedUrl: URL; try { parsedUrl = new URL(enrollmentUrl); } catch { console.error(`Error: invalid enrollment URL: ${enrollmentUrl}`); process.exit(1); } if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { console.error('Error: enrollment URL must use http or https'); process.exit(1); } const hostname = parsedUrl.hostname; const commonName = hostname.replace(/\./g, '-'); console.log(`Enrolling as peer with remote: ${enrollmentUrl}`); console.log(` Common name: ${commonName}`); // 2. Generate key pair and CSR via local gateway console.log('Generating key pair and CSR...'); const keypairResult = await apiRequest<{ peerId: string; csrPem: string }>( opts, 'POST', '/api/admin/federation/peers/keypair', { commonName, displayName: hostname }, ); const { peerId, csrPem } = keypairResult; console.log(` Peer ID: ${peerId}`); // 3. Submit CSR to remote enrollment endpoint console.log('Submitting CSR to remote enrollment endpoint...'); const remoteRes = await fetch(enrollmentUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ csrPem }), }); if (!remoteRes.ok) { const errText = await remoteRes.text(); throw new Error(`Remote enrollment failed (${remoteRes.status.toString()}): ${errText}`); } const remoteResult = (await remoteRes.json()) as { certPem: string; certChainPem: string }; if (!remoteResult.certPem) { throw new Error('Remote enrollment response missing certPem'); } // 4. Store the signed certificate in the local gateway console.log('Storing signed certificate...'); await apiRequest>( opts, 'PATCH', `/api/admin/federation/peers/${peerId}/cert`, { certPem: remoteResult.certPem }, ); console.log(`\nPeer enrolled successfully.`); console.log(` ID: ${peerId}`); console.log(` State: active`); } catch (err) { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); } }); }