diff --git a/apps/gateway/src/federation/__tests__/federation.controller.spec.ts b/apps/gateway/src/federation/__tests__/federation.controller.spec.ts new file mode 100644 index 0000000..0b2f4d1 --- /dev/null +++ b/apps/gateway/src/federation/__tests__/federation.controller.spec.ts @@ -0,0 +1,212 @@ +/** + * Unit tests for FederationController (FED-M2-08). + * + * Coverage: + * - listGrants: delegates to GrantsService with query params + * - createGrant: delegates to GrantsService, validates body + * - generateToken: returns enrollmentUrl containing the token + * - listPeers: returns DB rows + */ + +import 'reflect-metadata'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NotFoundException } from '@nestjs/common'; +import type { Db } from '@mosaicstack/db'; +import { FederationController } from '../federation.controller.js'; +import type { GrantsService } from '../grants.service.js'; +import type { EnrollmentService } from '../enrollment.service.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const GRANT_ID = 'g1111111-1111-1111-1111-111111111111'; +const PEER_ID = 'p2222222-2222-2222-2222-222222222222'; +const USER_ID = 'u3333333-3333-3333-3333-333333333333'; + +const MOCK_GRANT = { + id: GRANT_ID, + peerId: PEER_ID, + subjectUserId: USER_ID, + scope: { resources: ['tasks'], operations: ['list'] }, + status: 'pending' as const, + expiresAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + revokedAt: null, + revokedReason: null, +}; + +const MOCK_PEER = { + id: PEER_ID, + commonName: 'test-peer', + displayName: 'Test Peer', + certPem: '', + certSerial: 'pending', + certNotAfter: new Date(0), + clientKeyPem: null, + state: 'pending' as const, + endpointUrl: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +}; + +// --------------------------------------------------------------------------- +// DB mock builder +// --------------------------------------------------------------------------- + +function makeDbMock(rows: unknown[] = []) { + const orderBy = vi.fn().mockResolvedValue(rows); + const where = vi.fn().mockReturnValue({ orderBy }); + const from = vi.fn().mockReturnValue({ where, orderBy }); + const select = vi.fn().mockReturnValue({ from }); + + return { + select, + from, + where, + orderBy, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } as unknown as Db; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FederationController', () => { + let db: Db; + let grantsService: GrantsService; + let enrollmentService: EnrollmentService; + let controller: FederationController; + + beforeEach(() => { + db = makeDbMock([MOCK_PEER]); + + grantsService = { + createGrant: vi.fn().mockResolvedValue(MOCK_GRANT), + getGrant: vi.fn().mockResolvedValue(MOCK_GRANT), + listGrants: vi.fn().mockResolvedValue([MOCK_GRANT]), + revokeGrant: vi.fn().mockResolvedValue({ ...MOCK_GRANT, status: 'revoked' }), + activateGrant: vi.fn(), + expireGrant: vi.fn(), + } as unknown as GrantsService; + + enrollmentService = { + createToken: vi.fn().mockResolvedValue({ + token: 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12', + expiresAt: '2026-01-01T00:15:00.000Z', + }), + redeem: vi.fn(), + } as unknown as EnrollmentService; + + controller = new FederationController(db, grantsService, enrollmentService); + }); + + // ─── Grant management ────────────────────────────────────────────────── + + describe('listGrants', () => { + it('delegates to GrantsService with provided query params', async () => { + const query = { peerId: PEER_ID, status: 'pending' as const }; + const result = await controller.listGrants(query); + + expect(grantsService.listGrants).toHaveBeenCalledWith(query); + expect(result).toEqual([MOCK_GRANT]); + }); + + it('delegates to GrantsService with empty filters', async () => { + const result = await controller.listGrants({}); + + expect(grantsService.listGrants).toHaveBeenCalledWith({}); + expect(result).toEqual([MOCK_GRANT]); + }); + }); + + describe('createGrant', () => { + it('delegates to GrantsService and returns created grant', async () => { + const body = { + peerId: PEER_ID, + subjectUserId: USER_ID, + scope: { resources: ['tasks'], operations: ['list'] }, + }; + + const result = await controller.createGrant(body); + + expect(grantsService.createGrant).toHaveBeenCalledWith(body); + expect(result).toEqual(MOCK_GRANT); + }); + }); + + describe('getGrant', () => { + it('delegates to GrantsService with provided ID', async () => { + const result = await controller.getGrant(GRANT_ID); + + expect(grantsService.getGrant).toHaveBeenCalledWith(GRANT_ID); + expect(result).toEqual(MOCK_GRANT); + }); + }); + + describe('revokeGrant', () => { + it('delegates to GrantsService with id and reason', async () => { + const result = await controller.revokeGrant(GRANT_ID, { reason: 'test reason' }); + + expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, 'test reason'); + expect(result).toMatchObject({ status: 'revoked' }); + }); + + it('delegates without reason when omitted', async () => { + await controller.revokeGrant(GRANT_ID, {}); + + expect(grantsService.revokeGrant).toHaveBeenCalledWith(GRANT_ID, undefined); + }); + }); + + describe('generateToken', () => { + it('returns enrollmentUrl containing the token', async () => { + const token = 'abc123def456abc123def456abc123def456abc123def456abc123def456ab12'; + vi.mocked(enrollmentService.createToken).mockResolvedValueOnce({ + token, + expiresAt: '2026-01-01T00:15:00.000Z', + }); + + const result = await controller.generateToken(GRANT_ID, { ttlSeconds: 900 }); + + expect(result.token).toBe(token); + expect(result.enrollmentUrl).toContain(token); + expect(result.enrollmentUrl).toContain('/api/federation/enrollment/'); + }); + + it('creates token via EnrollmentService with correct grantId and peerId', async () => { + await controller.generateToken(GRANT_ID, { ttlSeconds: 300 }); + + expect(enrollmentService.createToken).toHaveBeenCalledWith({ + grantId: GRANT_ID, + peerId: PEER_ID, + ttlSeconds: 300, + }); + }); + + it('throws NotFoundException when grant does not exist', async () => { + vi.mocked(grantsService.getGrant).mockRejectedValueOnce( + new NotFoundException(`Grant ${GRANT_ID} not found`), + ); + + await expect(controller.generateToken(GRANT_ID, { ttlSeconds: 900 })).rejects.toThrow( + NotFoundException, + ); + }); + }); + + // ─── Peer management ─────────────────────────────────────────────────── + + describe('listPeers', () => { + it('returns DB rows ordered by commonName', async () => { + const result = await controller.listPeers(); + + expect(db.select).toHaveBeenCalled(); + // The DB mock resolves with [MOCK_PEER] + expect(result).toEqual([MOCK_PEER]); + }); + }); +}); diff --git a/apps/gateway/src/federation/federation-admin.dto.ts b/apps/gateway/src/federation/federation-admin.dto.ts new file mode 100644 index 0000000..e345bf1 --- /dev/null +++ b/apps/gateway/src/federation/federation-admin.dto.ts @@ -0,0 +1,39 @@ +/** + * DTOs for the federation admin controller (FED-M2-08). + */ + +import { IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min } from 'class-validator'; + +export class CreatePeerKeypairDto { + @IsString() + @IsNotEmpty() + commonName!: string; + + @IsString() + @IsNotEmpty() + displayName!: string; + + @IsOptional() + @IsUrl() + endpointUrl?: string; +} + +export class StorePeerCertDto { + @IsString() + @IsNotEmpty() + certPem!: string; +} + +export class GenerateEnrollmentTokenDto { + @IsOptional() + @IsInt() + @Min(60) + @Max(900) + ttlSeconds: number = 900; +} + +export class RevokeGrantBodyDto { + @IsOptional() + @IsString() + reason?: string; +} diff --git a/apps/gateway/src/federation/federation.controller.ts b/apps/gateway/src/federation/federation.controller.ts new file mode 100644 index 0000000..fcf8cbb --- /dev/null +++ b/apps/gateway/src/federation/federation.controller.ts @@ -0,0 +1,266 @@ +/** + * FederationController — admin REST API for federation management (FED-M2-08). + * + * Routes (all under /api/admin/federation, all require AdminGuard): + * + * Grant management: + * POST /api/admin/federation/grants + * GET /api/admin/federation/grants + * GET /api/admin/federation/grants/:id + * PATCH /api/admin/federation/grants/:id/revoke + * POST /api/admin/federation/grants/:id/tokens + * + * Peer management: + * GET /api/admin/federation/peers + * POST /api/admin/federation/peers/keypair + * PATCH /api/admin/federation/peers/:id/cert + * + * NOTE: The enrollment REDEMPTION endpoint (POST /api/federation/enrollment/:token) + * is handled by EnrollmentController — not duplicated here. + */ + +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Inject, + NotFoundException, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { webcrypto } from 'node:crypto'; +import { X509Certificate } from 'node:crypto'; +import { Pkcs10CertificateRequestGenerator } from '@peculiar/x509'; +import { type Db, eq, federationPeers } from '@mosaicstack/db'; +import { DB } from '../database/database.module.js'; +import { AdminGuard } from '../admin/admin.guard.js'; +import { GrantsService } from './grants.service.js'; +import { EnrollmentService } from './enrollment.service.js'; +import { sealClientKey } from './peer-key.util.js'; +import type { CreateGrantDto, ListGrantsDto } from './grants.dto.js'; +import type { + CreatePeerKeypairDto, + GenerateEnrollmentTokenDto, + RevokeGrantBodyDto, + StorePeerCertDto, +} from './federation-admin.dto.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Convert an ArrayBuffer to a Base64 string (for PEM encoding). + */ +function arrayBufferToBase64(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let binary = ''; + for (const b of bytes) { + binary += String.fromCharCode(b); + } + return Buffer.from(binary, 'binary').toString('base64'); +} + +/** + * Wrap a Base64 string in PEM armour. + */ +function toPem(label: string, b64: string): string { + const lines = b64.match(/.{1,64}/g) ?? []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`; +} + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +@Controller('api/admin/federation') +@UseGuards(AdminGuard) +export class FederationController { + constructor( + @Inject(DB) private readonly db: Db, + @Inject(GrantsService) private readonly grantsService: GrantsService, + @Inject(EnrollmentService) private readonly enrollmentService: EnrollmentService, + ) {} + + // ─── Grant management ──────────────────────────────────────────────────── + + /** + * POST /api/admin/federation/grants + * Create a new grant in pending state. + */ + @Post('grants') + @HttpCode(HttpStatus.CREATED) + async createGrant(@Body() body: CreateGrantDto) { + return this.grantsService.createGrant(body); + } + + /** + * GET /api/admin/federation/grants + * List grants with optional filters. + */ + @Get('grants') + async listGrants(@Query() query: ListGrantsDto) { + return this.grantsService.listGrants(query); + } + + /** + * GET /api/admin/federation/grants/:id + * Get a single grant by ID. + */ + @Get('grants/:id') + async getGrant(@Param('id') id: string) { + return this.grantsService.getGrant(id); + } + + /** + * PATCH /api/admin/federation/grants/:id/revoke + * Revoke an active grant. + */ + @Patch('grants/:id/revoke') + async revokeGrant(@Param('id') id: string, @Body() body: RevokeGrantBodyDto) { + return this.grantsService.revokeGrant(id, body.reason); + } + + /** + * POST /api/admin/federation/grants/:id/tokens + * Generate a single-use enrollment token for a pending grant. + * Returns the token plus an enrollmentUrl the operator shares out-of-band. + */ + @Post('grants/:id/tokens') + @HttpCode(HttpStatus.CREATED) + async generateToken(@Param('id') id: string, @Body() body: GenerateEnrollmentTokenDto) { + const grant = await this.grantsService.getGrant(id); + + const result = await this.enrollmentService.createToken({ + grantId: id, + peerId: grant.peerId, + ttlSeconds: body.ttlSeconds ?? 900, + }); + + const baseUrl = process.env['BETTER_AUTH_URL'] ?? 'http://localhost:14242'; + const enrollmentUrl = `${baseUrl}/api/federation/enrollment/${result.token}`; + + return { + token: result.token, + expiresAt: result.expiresAt, + enrollmentUrl, + }; + } + + // ─── Peer management ───────────────────────────────────────────────────── + + /** + * GET /api/admin/federation/peers + * List all federation peer rows. + */ + @Get('peers') + async listPeers() { + return this.db.select().from(federationPeers).orderBy(federationPeers.commonName); + } + + /** + * POST /api/admin/federation/peers/keypair + * Generate a new peer entry with EC P-256 key pair and a PKCS#10 CSR. + * + * Flow: + * 1. Generate EC P-256 key pair via webcrypto + * 2. Generate a self-signed CSR via @peculiar/x509 + * 3. Export private key as PEM + * 4. sealClientKey(privatePem) → sealed blob + * 5. Insert pending peer row + * 6. Return { peerId, csrPem } + */ + @Post('peers/keypair') + @HttpCode(HttpStatus.CREATED) + async createPeerKeypair(@Body() body: CreatePeerKeypairDto) { + // 1. Generate EC P-256 key pair via Web Crypto + const keyPair = await webcrypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, // extractable + ['sign', 'verify'], + ); + + // 2. Generate PKCS#10 CSR + const csr = await Pkcs10CertificateRequestGenerator.create({ + name: `CN=${body.commonName}`, + keys: keyPair, + signingAlgorithm: { name: 'ECDSA', hash: 'SHA-256' }, + }); + + const csrPem = csr.toString('pem'); + + // 3. Export private key as PKCS#8 PEM + const pkcs8Der = await webcrypto.subtle.exportKey('pkcs8', keyPair.privateKey); + const privatePem = toPem('PRIVATE KEY', arrayBufferToBase64(pkcs8Der)); + + // 4. Seal the private key + const sealed = sealClientKey(privatePem); + + // 5. Insert pending peer row + const [peer] = await this.db + .insert(federationPeers) + .values({ + commonName: body.commonName, + displayName: body.displayName, + certPem: '', + certSerial: 'pending', + certNotAfter: new Date(0), + clientKeyPem: sealed, + state: 'pending', + endpointUrl: body.endpointUrl, + }) + .returning(); + + return { + peerId: peer!.id, + csrPem, + }; + } + + /** + * PATCH /api/admin/federation/peers/:id/cert + * Store a signed certificate after enrollment completes. + * + * Flow: + * 1. Parse the cert to extract serial and notAfter + * 2. Update the peer row with cert data + state='active' + * 3. Return the updated peer row + */ + @Patch('peers/:id/cert') + async storePeerCert(@Param('id') id: string, @Body() body: StorePeerCertDto) { + // Ensure peer exists + const [existing] = await this.db + .select({ id: federationPeers.id }) + .from(federationPeers) + .where(eq(federationPeers.id, id)) + .limit(1); + + if (!existing) { + throw new NotFoundException(`Peer ${id} not found`); + } + + // 1. Parse cert + const x509 = new X509Certificate(body.certPem); + const certSerial = x509.serialNumber; + const certNotAfter = new Date(x509.validTo); + + // 2. Update peer + const [updated] = await this.db + .update(federationPeers) + .set({ + certPem: body.certPem, + certSerial, + certNotAfter, + state: 'active', + }) + .where(eq(federationPeers.id, id)) + .returning(); + + return updated; + } +} diff --git a/apps/gateway/src/federation/federation.module.ts b/apps/gateway/src/federation/federation.module.ts index 82b3708..ebb6295 100644 --- a/apps/gateway/src/federation/federation.module.ts +++ b/apps/gateway/src/federation/federation.module.ts @@ -3,10 +3,11 @@ import { AdminGuard } from '../admin/admin.guard.js'; import { CaService } from './ca.service.js'; import { EnrollmentController } from './enrollment.controller.js'; import { EnrollmentService } from './enrollment.service.js'; +import { FederationController } from './federation.controller.js'; import { GrantsService } from './grants.service.js'; @Module({ - controllers: [EnrollmentController], + controllers: [EnrollmentController, FederationController], providers: [AdminGuard, CaService, EnrollmentService, GrantsService], exports: [CaService, EnrollmentService, GrantsService], }) diff --git a/packages/mosaic/src/cli.ts b/packages/mosaic/src/cli.ts index 30efbdc..76237f5 100644 --- a/packages/mosaic/src/cli.ts +++ b/packages/mosaic/src/cli.ts @@ -18,6 +18,7 @@ import { registerUninstallCommand } from './commands/uninstall.js'; // prdy is registered via launch.ts import { registerLaunchCommands } from './commands/launch.js'; import { registerAuthCommand } from './commands/auth.js'; +import { registerFederationCommand } from './commands/federation.js'; import { registerGatewayCommand } from './commands/gateway.js'; import { backgroundUpdateCheck, @@ -336,6 +337,10 @@ registerAuthCommand(program); registerGatewayCommand(program); +// ─── federation ─────────────────────────────────────────────────────── + +registerFederationCommand(program); + // ─── agent ───────────────────────────────────────────────────────────── registerAgentCommand(program); diff --git a/packages/mosaic/src/commands/federation.ts b/packages/mosaic/src/commands/federation.ts new file mode 100644 index 0000000..cb00163 --- /dev/null +++ b/packages/mosaic/src/commands/federation.ts @@ -0,0 +1,410 @@ +/** + * `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); + } + }); +}