import type { FastifyInstance } from 'fastify'; import { eq, and, count } from 'drizzle-orm'; import { randomUUID } from 'crypto'; import { servers, allocations, nodes, games } from '@source/database'; import type { PowerAction } from '@source/shared'; import { AppError } from '../../lib/errors.js'; import { requirePermission } from '../../lib/permissions.js'; import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; import { createAuditLog } from '../../lib/audit.js'; import { ServerParamSchema, CreateServerSchema, UpdateServerSchema, PowerActionSchema, } from './schemas.js'; export default async function serverRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate); // GET /api/organizations/:orgId/servers app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'server.read'); const { page, perPage, offset, limit } = paginate(request.query as any); const [totalResult] = await app.db .select({ count: count() }) .from(servers) .where(eq(servers.organizationId, orgId)); const serverList = await app.db .select({ id: servers.id, uuid: servers.uuid, name: servers.name, description: servers.description, status: servers.status, memoryLimit: servers.memoryLimit, diskLimit: servers.diskLimit, cpuLimit: servers.cpuLimit, port: servers.port, createdAt: servers.createdAt, nodeName: nodes.name, nodeId: nodes.id, gameName: games.name, gameSlug: games.slug, gameId: games.id, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .innerJoin(games, eq(servers.gameId, games.id)) .where(eq(servers.organizationId, orgId)) .limit(limit) .offset(offset) .orderBy(servers.createdAt); return paginatedResponse(serverList, totalResult!.count, page, perPage); }); // POST /api/organizations/:orgId/servers app.post('/', { schema: CreateServerSchema }, async (request, reply) => { const { orgId } = request.params as { orgId: string }; await requirePermission(request, orgId, 'server.create'); const body = request.body as { name: string; description?: string; nodeId: string; gameId: string; memoryLimit: number; diskLimit: number; cpuLimit?: number; allocationId: string; environment?: Record; startupOverride?: string; }; // Verify node belongs to org const node = await app.db.query.nodes.findFirst({ where: and(eq(nodes.id, body.nodeId), eq(nodes.organizationId, orgId)), }); if (!node) throw AppError.notFound('Node not found in this organization'); // Verify game exists const game = await app.db.query.games.findFirst({ where: eq(games.id, body.gameId), }); if (!game) throw AppError.notFound('Game not found'); // Verify and claim allocation const allocation = await app.db.query.allocations.findFirst({ where: and( eq(allocations.id, body.allocationId), eq(allocations.nodeId, body.nodeId), ), }); if (!allocation) throw AppError.notFound('Allocation not found on this node'); if (allocation.serverId) throw AppError.conflict('Allocation is already in use'); const serverUuid = randomUUID().slice(0, 8); const [server] = await app.db .insert(servers) .values({ uuid: serverUuid, organizationId: orgId, nodeId: body.nodeId, gameId: body.gameId, name: body.name, description: body.description, memoryLimit: body.memoryLimit, diskLimit: body.diskLimit, cpuLimit: body.cpuLimit ?? 100, port: allocation.port, environment: body.environment ?? {}, startupOverride: body.startupOverride, status: 'installing', }) .returning(); // Assign allocation to server await app.db .update(allocations) .set({ serverId: server!.id, isDefault: true }) .where(eq(allocations.id, body.allocationId)); // TODO: Send gRPC CreateServer to daemon // This will be implemented in Phase 4 await createAuditLog(app.db, request, { organizationId: orgId, serverId: server!.id, action: 'server.create', metadata: { name: body.name, gameSlug: game.slug, nodeId: body.nodeId }, }); return reply.code(201).send(server); }); // GET /api/organizations/:orgId/servers/:serverId app.get('/:serverId', { schema: ServerParamSchema }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.read'); const [server] = await app.db .select({ id: servers.id, uuid: servers.uuid, name: servers.name, description: servers.description, status: servers.status, memoryLimit: servers.memoryLimit, diskLimit: servers.diskLimit, cpuLimit: servers.cpuLimit, port: servers.port, additionalPorts: servers.additionalPorts, environment: servers.environment, startupOverride: servers.startupOverride, installedAt: servers.installedAt, createdAt: servers.createdAt, updatedAt: servers.updatedAt, nodeId: nodes.id, nodeName: nodes.name, nodeFqdn: nodes.fqdn, gameId: games.id, gameName: games.name, gameSlug: games.slug, }) .from(servers) .innerJoin(nodes, eq(servers.nodeId, nodes.id)) .innerJoin(games, eq(servers.gameId, games.id)) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))); if (!server) throw AppError.notFound('Server not found'); return server; }); // PATCH /api/organizations/:orgId/servers/:serverId app.patch('/:serverId', { schema: { ...ServerParamSchema, ...UpdateServerSchema } }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.update'); const body = request.body as Record; const [updated] = await app.db .update(servers) .set({ ...body, updatedAt: new Date() }) .where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId))) .returning(); if (!updated) throw AppError.notFound('Server not found'); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'server.update', metadata: body, }); return updated; }); // DELETE /api/organizations/:orgId/servers/:serverId app.delete('/:serverId', { schema: ServerParamSchema }, async (request, reply) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; await requirePermission(request, orgId, 'server.delete'); const server = await app.db.query.servers.findFirst({ where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), }); if (!server) throw AppError.notFound('Server not found'); // Release allocations await app.db .update(allocations) .set({ serverId: null }) .where(eq(allocations.serverId, serverId)); // TODO: Send gRPC DeleteServer to daemon await app.db.delete(servers).where(eq(servers.id, serverId)); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: 'server.delete', metadata: { name: server.name, uuid: server.uuid }, }); return reply.code(204).send(); }); // POST /api/organizations/:orgId/servers/:serverId/power app.post('/:serverId/power', { schema: { ...ServerParamSchema, ...PowerActionSchema } }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; const { action } = request.body as { action: PowerAction }; // Check specific power permission const permMap = { start: 'power.start', stop: 'power.stop', restart: 'power.restart', kill: 'power.kill', } as const; await requirePermission(request, orgId, permMap[action]); const server = await app.db.query.servers.findFirst({ where: and(eq(servers.id, serverId), eq(servers.organizationId, orgId)), }); if (!server) throw AppError.notFound('Server not found'); if (server.status === 'suspended') { throw AppError.badRequest('Cannot send power action to a suspended server'); } // TODO: Send gRPC SetPowerState to daemon // For now, just update status optimistically const statusMap: Record = { start: 'running', stop: 'stopped', restart: 'running', kill: 'stopped', }; await app.db .update(servers) .set({ status: statusMap[action] as any, updatedAt: new Date() }) .where(eq(servers.id, serverId)); await createAuditLog(app.db, request, { organizationId: orgId, serverId, action: `server.power.${action}`, }); return { success: true, action }; }); }