import { Router, Request, Response } from 'express'; import rateLimit from 'express-rate-limit'; import { prisma } from '../../lib/prisma'; import { Prisma, AuditAction, InstanceStatus, AgentRegistrationStatus } from '@prisma/client'; import { validateInviteCode, markCodeUsed } from '../../services/invite-code.service'; import { issueAgentCert } from '../../services/certificate.service'; import { authenticate, requireRole } from '../../middleware/auth'; import { AppError } from '../../middleware/error-handler'; import { logger } from '../../utils/logger'; const router = Router(); // SECURITY: Strict rate limiter for unauthenticated agent endpoints const agentRegistrationLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // 10 attempts per window per IP standardHeaders: true, legacyHeaders: false, message: { error: 'RATE_LIMITED', message: 'Too many registration attempts, try again later' }, }); // ─── Public Endpoints (used by remote agents during phone-home) ────── /** * POST /api/agents/register * Agent phones home with invite code + instance metadata. * Creates a PENDING registration for admin approval. */ router.post('/register', agentRegistrationLimiter, async (req: Request, res: Response) => { const { inviteCode, slug, name, domain, agentUrl, basePath, composeProject, metadata } = req.body; if (!inviteCode || !slug || !agentUrl) { throw new AppError(400, 'inviteCode, slug, and agentUrl are required'); } // Validate invite code const invite = await validateInviteCode(inviteCode); // Check for duplicate pending registrations const existing = await prisma.agentRegistration.findFirst({ where: { slug, status: AgentRegistrationStatus.PENDING }, }); if (existing) { res.json({ registrationId: existing.id, status: 'PENDING' }); return; } // Create pending registration const registration = await prisma.agentRegistration.create({ data: { inviteCodeId: invite.id, slug: slug || '', name: name || slug || '', domain: domain || '', agentUrl, basePath: basePath || '', composeProject: composeProject || slug || '', metadata: metadata || null, }, }); logger.info(`[agents] New registration request: ${slug} from ${agentUrl} (invite: ${invite.code})`); res.status(201).json({ registrationId: registration.id, status: 'PENDING', message: 'Registration submitted — waiting for admin approval', }); }); /** * GET /api/agents/poll * Agent polls to check if registration was approved. * Returns cert bundle on approval. */ router.get('/poll', agentRegistrationLimiter, async (req: Request, res: Response) => { const { registrationId, slug } = req.query; if (!registrationId && !slug) { throw new AppError(400, 'registrationId or slug required'); } const registration = await prisma.agentRegistration.findFirst({ where: registrationId ? { id: registrationId as string } : { slug: slug as string, status: { in: [AgentRegistrationStatus.PENDING, AgentRegistrationStatus.APPROVED] } }, orderBy: { createdAt: 'desc' }, }); if (!registration) { throw new AppError(404, 'Registration not found'); } if (registration.status === AgentRegistrationStatus.APPROVED && registration.certBundle) { // Return cert bundle — agent will save certs and restart with mTLS const bundle = registration.certBundle; // SECURITY: Wipe the cert bundle (contains private key) after first delivery. // The agent gets one chance to retrieve it; after that it's gone from the DB. await prisma.agentRegistration.update({ where: { id: registration.id }, data: { certBundle: Prisma.DbNull }, }); logger.info(`[agents] Cert bundle delivered and wiped for ${registration.slug}`); res.json({ status: 'APPROVED', certBundle: bundle, }); return; } if (registration.status === AgentRegistrationStatus.APPROVED && !registration.certBundle) { // Cert bundle was already delivered and wiped — agent must re-issue if it missed it res.json({ status: 'APPROVED', certBundle: null, message: 'Certificate bundle already delivered. Contact admin to re-issue.' }); return; } if (registration.status === AgentRegistrationStatus.REJECTED) { res.json({ status: 'REJECTED' }); return; } res.json({ status: 'PENDING' }); }); // ─── Authenticated Endpoints (CCP admin) ───────────────────────────── /** * GET /api/agents/registrations * List all agent registrations (pending, approved, rejected). */ router.get('/registrations', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (_req: Request, res: Response) => { const registrations = await prisma.agentRegistration.findMany({ orderBy: { createdAt: 'desc' }, take: 100, }); res.json(registrations); }); /** * POST /api/agents/registrations/:id/approve * Approve a pending registration: issue certs, create Instance, mark approved. */ router.post('/registrations/:id/approve', authenticate, requireRole('SUPER_ADMIN'), async (req: Request, res: Response) => { const { id } = req.params; const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } }); if (!registration) throw new AppError(404, 'Registration not found'); if (registration.status !== AgentRegistrationStatus.PENDING) { throw new AppError(400, `Registration is ${registration.status}, not PENDING`); } // Create the Instance record const instance = await prisma.instance.create({ data: { slug: registration.slug, name: registration.name, domain: registration.domain, status: InstanceStatus.STOPPED, statusMessage: 'Remote instance registered — agent connecting', basePath: registration.basePath, composeProject: registration.composeProject, portConfig: (registration.metadata as Record)?.portConfig || { api: 4000, admin: 3000, postgres: 5432, nginx: 80 }, isRegistered: true, isRemote: true, agentUrl: registration.agentUrl, adminEmail: (registration.metadata as Record)?.adminEmail as string || 'admin@example.com', }, }); // Issue mTLS certificates const certMaterials = await issueAgentCert(instance.id, registration.slug); // Mark invite code as used const invite = await prisma.agentInviteCode.findUnique({ where: { id: registration.inviteCodeId } }); if (invite && !invite.usedAt) { await markCodeUsed(invite.code, instance.id); } // Update registration with approval + cert bundle await prisma.agentRegistration.update({ where: { id: id as string }, data: { status: AgentRegistrationStatus.APPROVED, instanceId: instance.id, approvedById: (req as unknown as { user: { id: string } }).user.id, approvedAt: new Date(), certBundle: { caCertPem: certMaterials.caCertPem, agentCertPem: certMaterials.agentCertPem, agentKeyPem: certMaterials.agentKeyPem, ccpFingerprint: certMaterials.caFingerprint, }, }, }); // Audit log await prisma.auditLog.create({ data: { userId: (req as unknown as { user: { id: string } }).user.id, instanceId: instance.id, action: AuditAction.AGENT_APPROVE, details: { slug: registration.slug, agentUrl: registration.agentUrl }, ipAddress: req.ip || null, }, }); logger.info(`[agents] Registration approved: ${registration.slug} → instance ${instance.id}`); res.json({ message: 'Registration approved — agent will receive certificates on next poll', instanceId: instance.id, }); }); /** * POST /api/agents/registrations/:id/reject * Reject a pending registration. */ router.post('/registrations/:id/reject', authenticate, requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => { const { id } = req.params; const registration = await prisma.agentRegistration.findUnique({ where: { id: id as string } }); if (!registration) throw new AppError(404, 'Registration not found'); if (registration.status !== AgentRegistrationStatus.PENDING) { throw new AppError(400, `Registration is ${registration.status}, not PENDING`); } await prisma.agentRegistration.update({ where: { id: id as string }, data: { status: AgentRegistrationStatus.REJECTED, rejectedAt: new Date(), }, }); await prisma.auditLog.create({ data: { userId: (req as unknown as { user: { id: string } }).user.id, action: AuditAction.AGENT_REJECT, details: { slug: registration.slug, agentUrl: registration.agentUrl }, ipAddress: req.ip || null, }, }); res.json({ message: 'Registration rejected' }); }); export default router;