import type { FastifyInstance } from 'fastify'; import { eq, and } from 'drizzle-orm'; import { randomBytes } from 'crypto'; import { nodes, allocations } from '@source/database'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { createAuditLog } from '../../lib/audit.js'; import { NodeParamSchema, CreateNodeSchema, UpdateNodeSchema, CreateAllocationSchema, } from './schemas.js'; export default async function nodeRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); // GET /api/organizations/:orgId/nodes app.get('/', async (request) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'node.read'); const nodeList = await app.db .select() .from(nodes) .where(eq(nodes.organizationId, orgId)) .orderBy(nodes.createdAt); return { data: nodeList }; }); // POST /api/organizations/:orgId/nodes app.post('/', { schema: CreateNodeSchema }, async (request, reply) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'node.manage'); const body = request.body as { name: string; fqdn: string; daemonPort?: number; grpcPort?: number; location?: string; memoryTotal: number; diskTotal: number; memoryOveralloc?: number; diskOveralloc?: number; }; const daemonToken = randomBytes(32).toString('hex'); const [node] = await app.db .insert(nodes) .values({ organizationId: orgId, ...body, daemonToken, }) .returning(); await createAuditLog(app.db, request, { organizationId: orgId, action: 'node.create', metadata: { nodeId: node!.id, name: body.name }, }); return reply.code(201).send(node); }); // GET /api/organizations/:orgId/nodes/:nodeId app.get('/:nodeId', { schema: NodeParamSchema }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.read'); const node = await app.db.query.nodes.findFirst({ where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)), }); if (!node) throw AppError.notFound('Node not found'); return node; }); // PATCH /api/organizations/:orgId/nodes/:nodeId app.patch('/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.manage'); const body = request.body as Record; const [updated] = await app.db .update(nodes) .set({ ...body, updatedAt: new Date() }) .where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId))) .returning(); if (!updated) throw AppError.notFound('Node not found'); await createAuditLog(app.db, request, { organizationId: orgId, action: 'node.update', metadata: { nodeId, ...body }, }); return updated; }); // DELETE /api/organizations/:orgId/nodes/:nodeId app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.manage'); const node = await app.db.query.nodes.findFirst({ where: and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId)), }); if (!node) throw AppError.notFound('Node not found'); await app.db.delete(nodes).where(eq(nodes.id, nodeId)); await createAuditLog(app.db, request, { organizationId: orgId, action: 'node.delete', metadata: { nodeId, name: node.name }, }); return reply.code(204).send(); }); // === Allocations === // GET /api/organizations/:orgId/nodes/:nodeId/allocations app.get('/:nodeId/allocations', { schema: NodeParamSchema }, async (request) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.read'); const allocs = await app.db .select() .from(allocations) .where(eq(allocations.nodeId, nodeId)) .orderBy(allocations.port); return { data: allocs }; }); // POST /api/organizations/:orgId/nodes/:nodeId/allocations app.post('/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => { const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; await requirePermission(request, orgId, 'node.manage'); const { ip, ports } = request.body as { ip: string; ports: number[] }; const values = ports.map((port) => ({ nodeId, ip, port, })); const created = await app.db .insert(allocations) .values(values) .onConflictDoNothing() .returning(); await createAuditLog(app.db, request, { organizationId: orgId, action: 'allocation.create', metadata: { nodeId, ip, ports }, }); return reply.code(201).send({ data: created }); }); }