/** * 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 { CreateGrantDto, ListGrantsDto } from './grants.dto.js'; import { 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; } }