diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8fff519..1096fc4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -4,6 +4,10 @@ import cookie from '@fastify/cookie'; import dbPlugin from './plugins/db.js'; import authPlugin from './plugins/auth.js'; import authRoutes from './routes/auth/index.js'; +import organizationRoutes from './routes/organizations/index.js'; +import nodeRoutes from './routes/nodes/index.js'; +import serverRoutes from './routes/servers/index.js'; +import adminRoutes from './routes/admin/index.js'; import { AppError } from './lib/errors.js'; const app = Fastify({ @@ -56,6 +60,17 @@ app.get('/api/health', async () => { }); await app.register(authRoutes, { prefix: '/api/auth' }); +await app.register(organizationRoutes, { prefix: '/api/organizations' }); +await app.register(adminRoutes, { prefix: '/api/admin' }); + +// Nested org routes: nodes and servers are scoped to an org +await app.register( + async (orgScope) => { + await orgScope.register(nodeRoutes, { prefix: '/nodes' }); + await orgScope.register(serverRoutes, { prefix: '/servers' }); + }, + { prefix: '/api/organizations/:orgId' }, +); // Start const PORT = Number(process.env.PORT) || 3000; diff --git a/apps/api/src/lib/audit.ts b/apps/api/src/lib/audit.ts new file mode 100644 index 0000000..2dca8c1 --- /dev/null +++ b/apps/api/src/lib/audit.ts @@ -0,0 +1,23 @@ +import type { FastifyRequest } from 'fastify'; +import { auditLogs } from '@source/database'; +import type { Database } from '@source/database'; + +export async function createAuditLog( + db: Database, + request: FastifyRequest, + data: { + organizationId: string; + action: string; + serverId?: string; + metadata?: Record; + }, +) { + await db.insert(auditLogs).values({ + organizationId: data.organizationId, + userId: request.user.sub, + serverId: data.serverId, + action: data.action, + metadata: data.metadata ?? {}, + ipAddress: request.ip, + }); +} diff --git a/apps/api/src/lib/pagination.ts b/apps/api/src/lib/pagination.ts new file mode 100644 index 0000000..092ff26 --- /dev/null +++ b/apps/api/src/lib/pagination.ts @@ -0,0 +1,25 @@ +import { Type } from '@sinclair/typebox'; + +export const PaginationQuerySchema = Type.Object({ + page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), + perPage: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })), +}); + +export function paginate(query: { page?: number; perPage?: number }) { + const page = query.page ?? 1; + const perPage = query.perPage ?? 20; + const offset = (page - 1) * perPage; + return { page, perPage, offset, limit: perPage }; +} + +export function paginatedResponse(data: T[], total: number, page: number, perPage: number) { + return { + data, + meta: { + page, + perPage, + total, + totalPages: Math.ceil(total / perPage), + }, + }; +} diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts new file mode 100644 index 0000000..a2010e6 --- /dev/null +++ b/apps/api/src/lib/permissions.ts @@ -0,0 +1,82 @@ +import type { FastifyRequest } from 'fastify'; +import { eq, and } from 'drizzle-orm'; +import { organizationMembers } from '@source/database'; +import { ROLES } from '@source/shared'; +import type { Permission, Role } from '@source/shared'; +import { AppError } from './errors.js'; + +interface OrgMember { + role: Role; + customPermissions: Record; +} + +/** + * Get the requesting user's membership in an organization. + * Super admins bypass membership checks. + */ +export async function getOrgMembership( + request: FastifyRequest, + orgId: string, +): Promise { + const user = request.user; + + if (user.isSuperAdmin) { + return 'super_admin'; + } + + const member = await (request.server as any).db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.organizationId, orgId), + eq(organizationMembers.userId, user.sub), + ), + }); + + if (!member) { + throw AppError.forbidden('You are not a member of this organization'); + } + + return { + role: member.role as Role, + customPermissions: (member.customPermissions ?? {}) as Record, + }; +} + +/** + * Check if the user has a specific permission in the organization. + * Super admins always have all permissions. + */ +export function hasPermission(membership: OrgMember | 'super_admin', permission: Permission): boolean { + if (membership === 'super_admin') return true; + + // Check custom permission overrides first + if (permission in membership.customPermissions) { + return membership.customPermissions[permission]!; + } + + // Fall back to role defaults + const rolePerms = ROLES[membership.role]?.permissions ?? []; + return (rolePerms as readonly string[]).includes(permission); +} + +/** + * Require a specific permission, throw 403 if not allowed. + */ +export async function requirePermission( + request: FastifyRequest, + orgId: string, + permission: Permission, +): Promise { + const membership = await getOrgMembership(request, orgId); + if (!hasPermission(membership, permission)) { + throw AppError.forbidden(`Missing permission: ${permission}`); + } +} + +/** + * Require super admin role. + */ +export function requireSuperAdmin(request: FastifyRequest): void { + if (!request.user.isSuperAdmin) { + throw AppError.forbidden('Super admin access required'); + } +} diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts new file mode 100644 index 0000000..94f3b1c --- /dev/null +++ b/apps/api/src/routes/admin/index.ts @@ -0,0 +1,140 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, desc, count } from 'drizzle-orm'; +import { users, games, nodes, auditLogs } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requireSuperAdmin } from '../../lib/permissions.js'; +import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; +import { CreateGameSchema, UpdateGameSchema, GameIdParamSchema } from './schemas.js'; + +export default async function adminRoutes(app: FastifyInstance) { + // All admin routes require auth + super admin + app.addHook('onRequest', app.authenticate); + app.addHook('onRequest', async (request) => { + requireSuperAdmin(request); + }); + + // === Users === + + // GET /api/admin/users + app.get('/users', { schema: { querystring: PaginationQuerySchema } }, async (request) => { + const { page, perPage, offset, limit } = paginate(request.query as any); + + const [totalResult] = await app.db.select({ count: count() }).from(users); + + const userList = await app.db + .select({ + id: users.id, + email: users.email, + username: users.username, + isSuperAdmin: users.isSuperAdmin, + avatarUrl: users.avatarUrl, + createdAt: users.createdAt, + }) + .from(users) + .limit(limit) + .offset(offset) + .orderBy(users.createdAt); + + return paginatedResponse(userList, totalResult!.count, page, perPage); + }); + + // === Games === + + // GET /api/admin/games + app.get('/games', async () => { + const gameList = await app.db + .select() + .from(games) + .orderBy(games.name); + + return { data: gameList }; + }); + + // POST /api/admin/games + app.post('/games', { schema: CreateGameSchema }, async (request, reply) => { + const body = request.body as { + slug: string; + name: string; + dockerImage: string; + defaultPort: number; + startupCommand: string; + stopCommand?: string; + configFiles?: unknown[]; + environmentVars?: unknown[]; + }; + + const existing = await app.db.query.games.findFirst({ + where: eq(games.slug, body.slug), + }); + if (existing) throw AppError.conflict('Game slug already exists'); + + const [game] = await app.db + .insert(games) + .values({ + ...body, + configFiles: body.configFiles ?? [], + environmentVars: body.environmentVars ?? [], + }) + .returning(); + + return reply.code(201).send(game); + }); + + // PATCH /api/admin/games/:gameId + app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => { + const { gameId } = request.params as { gameId: string }; + const body = request.body as Record; + + const [updated] = await app.db + .update(games) + .set({ ...body, updatedAt: new Date() }) + .where(eq(games.id, gameId)) + .returning(); + + if (!updated) throw AppError.notFound('Game not found'); + + return updated; + }); + + // === Nodes (global view) === + + // GET /api/admin/nodes + app.get('/nodes', async () => { + const nodeList = await app.db + .select() + .from(nodes) + .orderBy(nodes.createdAt); + + return { data: nodeList }; + }); + + // === Audit Logs === + + // GET /api/admin/audit-logs + app.get('/audit-logs', { schema: { querystring: PaginationQuerySchema } }, async (request) => { + const { page, perPage, offset, limit } = paginate(request.query as any); + + const [totalResult] = await app.db.select({ count: count() }).from(auditLogs); + + const logs = await app.db + .select({ + id: auditLogs.id, + organizationId: auditLogs.organizationId, + userId: auditLogs.userId, + serverId: auditLogs.serverId, + action: auditLogs.action, + metadata: auditLogs.metadata, + ipAddress: auditLogs.ipAddress, + createdAt: auditLogs.createdAt, + userEmail: users.email, + userName: users.username, + }) + .from(auditLogs) + .innerJoin(users, eq(auditLogs.userId, users.id)) + .orderBy(desc(auditLogs.createdAt)) + .limit(limit) + .offset(offset); + + return paginatedResponse(logs, totalResult!.count, page, perPage); + }); +} diff --git a/apps/api/src/routes/admin/schemas.ts b/apps/api/src/routes/admin/schemas.ts new file mode 100644 index 0000000..a5c185d --- /dev/null +++ b/apps/api/src/routes/admin/schemas.ts @@ -0,0 +1,32 @@ +import { Type } from '@sinclair/typebox'; + +export const CreateGameSchema = { + body: Type.Object({ + slug: Type.String({ minLength: 1, maxLength: 100, pattern: '^[a-z0-9-]+$' }), + name: Type.String({ minLength: 1, maxLength: 255 }), + dockerImage: Type.String({ minLength: 1 }), + defaultPort: Type.Number({ minimum: 1, maximum: 65535 }), + startupCommand: Type.String({ minLength: 1 }), + stopCommand: Type.Optional(Type.String()), + configFiles: Type.Optional(Type.Array(Type.Any())), + environmentVars: Type.Optional(Type.Array(Type.Any())), + }), +}; + +export const UpdateGameSchema = { + body: Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + dockerImage: Type.Optional(Type.String({ minLength: 1 })), + defaultPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })), + startupCommand: Type.Optional(Type.String({ minLength: 1 })), + stopCommand: Type.Optional(Type.String()), + configFiles: Type.Optional(Type.Array(Type.Any())), + environmentVars: Type.Optional(Type.Array(Type.Any())), + }), +}; + +export const GameIdParamSchema = { + params: Type.Object({ + gameId: Type.String({ format: 'uuid' }), + }), +}; diff --git a/apps/api/src/routes/nodes/index.ts b/apps/api/src/routes/nodes/index.ts new file mode 100644 index 0000000..677766a --- /dev/null +++ b/apps/api/src/routes/nodes/index.ts @@ -0,0 +1,170 @@ +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 }); + }); +} diff --git a/apps/api/src/routes/nodes/schemas.ts b/apps/api/src/routes/nodes/schemas.ts new file mode 100644 index 0000000..5e8519c --- /dev/null +++ b/apps/api/src/routes/nodes/schemas.ts @@ -0,0 +1,43 @@ +import { Type } from '@sinclair/typebox'; + +export const NodeParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + nodeId: Type.String({ format: 'uuid' }), + }), +}; + +export const CreateNodeSchema = { + body: Type.Object({ + name: Type.String({ minLength: 1, maxLength: 255 }), + fqdn: Type.String({ minLength: 1, maxLength: 255 }), + daemonPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535, default: 8443 })), + grpcPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535, default: 50051 })), + location: Type.Optional(Type.String({ maxLength: 255 })), + memoryTotal: Type.Number({ minimum: 0 }), + diskTotal: Type.Number({ minimum: 0 }), + memoryOveralloc: Type.Optional(Type.Number({ minimum: 0, default: 0 })), + diskOveralloc: Type.Optional(Type.Number({ minimum: 0, default: 0 })), + }), +}; + +export const UpdateNodeSchema = { + body: Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + fqdn: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + daemonPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })), + grpcPort: Type.Optional(Type.Number({ minimum: 1, maximum: 65535 })), + location: Type.Optional(Type.String({ maxLength: 255 })), + memoryTotal: Type.Optional(Type.Number({ minimum: 0 })), + diskTotal: Type.Optional(Type.Number({ minimum: 0 })), + memoryOveralloc: Type.Optional(Type.Number({ minimum: 0 })), + diskOveralloc: Type.Optional(Type.Number({ minimum: 0 })), + }), +}; + +export const CreateAllocationSchema = { + body: Type.Object({ + ip: Type.String({ minLength: 1, maxLength: 45 }), + ports: Type.Array(Type.Number({ minimum: 1, maximum: 65535 }), { minItems: 1 }), + }), +}; diff --git a/apps/api/src/routes/organizations/index.ts b/apps/api/src/routes/organizations/index.ts new file mode 100644 index 0000000..455921a --- /dev/null +++ b/apps/api/src/routes/organizations/index.ts @@ -0,0 +1,276 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, count } from 'drizzle-orm'; +import { organizations, organizationMembers, users } from '@source/database'; +import { AppError } from '../../lib/errors.js'; +import { requirePermission, getOrgMembership } from '../../lib/permissions.js'; +import { paginate, paginatedResponse, PaginationQuerySchema } from '../../lib/pagination.js'; +import { createAuditLog } from '../../lib/audit.js'; +import { + CreateOrgSchema, + UpdateOrgSchema, + OrgIdParamSchema, + AddMemberSchema, + UpdateMemberSchema, + MemberIdParamSchema, +} from './schemas.js'; + +export default async function organizationRoutes(app: FastifyInstance) { + // All org routes require authentication + app.addHook('onRequest', app.authenticate); + + // GET /api/organizations — list user's organizations + app.get('/', { schema: { querystring: PaginationQuerySchema } }, async (request) => { + const { page, perPage, offset, limit } = paginate(request.query as any); + const userId = request.user.sub; + + if (request.user.isSuperAdmin) { + const [totalResult] = await app.db.select({ count: count() }).from(organizations); + const orgs = await app.db + .select() + .from(organizations) + .limit(limit) + .offset(offset) + .orderBy(organizations.createdAt); + return paginatedResponse(orgs, totalResult!.count, page, perPage); + } + + const memberOrgs = await app.db + .select({ + id: organizations.id, + name: organizations.name, + slug: organizations.slug, + ownerId: organizations.ownerId, + maxServers: organizations.maxServers, + maxNodes: organizations.maxNodes, + createdAt: organizations.createdAt, + updatedAt: organizations.updatedAt, + role: organizationMembers.role, + }) + .from(organizationMembers) + .innerJoin(organizations, eq(organizationMembers.organizationId, organizations.id)) + .where(eq(organizationMembers.userId, userId)) + .limit(limit) + .offset(offset); + + const [totalResult] = await app.db + .select({ count: count() }) + .from(organizationMembers) + .where(eq(organizationMembers.userId, userId)); + + return paginatedResponse(memberOrgs, totalResult!.count, page, perPage); + }); + + // POST /api/organizations — create organization + app.post('/', { schema: CreateOrgSchema }, async (request, reply) => { + const { name, slug } = request.body as { name: string; slug: string }; + + const existing = await app.db.query.organizations.findFirst({ + where: eq(organizations.slug, slug), + }); + if (existing) { + throw AppError.conflict('Organization slug already in use', 'SLUG_TAKEN'); + } + + const [org] = await app.db + .insert(organizations) + .values({ + name, + slug, + ownerId: request.user.sub, + }) + .returning(); + + // Add creator as admin member + await app.db.insert(organizationMembers).values({ + organizationId: org!.id, + userId: request.user.sub, + role: 'admin', + }); + + return reply.code(201).send(org); + }); + + // GET /api/organizations/:orgId + app.get('/:orgId', { schema: OrgIdParamSchema }, async (request) => { + const { orgId } = request.params as { orgId: string }; + await getOrgMembership(request, orgId); + + const org = await app.db.query.organizations.findFirst({ + where: eq(organizations.id, orgId), + }); + if (!org) throw AppError.notFound('Organization not found'); + + return org; + }); + + // PATCH /api/organizations/:orgId + app.patch('/:orgId', { schema: { ...OrgIdParamSchema, ...UpdateOrgSchema } }, async (request) => { + const { orgId } = request.params as { orgId: string }; + await requirePermission(request, orgId, 'org.settings'); + + const body = request.body as { name?: string; maxServers?: number; maxNodes?: number }; + + const [updated] = await app.db + .update(organizations) + .set({ ...body, updatedAt: new Date() }) + .where(eq(organizations.id, orgId)) + .returning(); + + if (!updated) throw AppError.notFound('Organization not found'); + + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'organization.update', + metadata: body, + }); + + return updated; + }); + + // DELETE /api/organizations/:orgId + app.delete('/:orgId', { schema: OrgIdParamSchema }, async (request, reply) => { + const { orgId } = request.params as { orgId: string }; + const membership = await getOrgMembership(request, orgId); + + // Only owner or super admin can delete + const org = await app.db.query.organizations.findFirst({ + where: eq(organizations.id, orgId), + }); + if (!org) throw AppError.notFound('Organization not found'); + + if (membership !== 'super_admin' && org.ownerId !== request.user.sub) { + throw AppError.forbidden('Only the organization owner can delete this organization'); + } + + await app.db.delete(organizations).where(eq(organizations.id, orgId)); + + return reply.code(204).send(); + }); + + // === Members === + + // GET /api/organizations/:orgId/members + app.get('/:orgId/members', { schema: OrgIdParamSchema }, async (request) => { + const { orgId } = request.params as { orgId: string }; + await requirePermission(request, orgId, 'org.members'); + + const members = await app.db + .select({ + id: organizationMembers.id, + userId: organizationMembers.userId, + role: organizationMembers.role, + customPermissions: organizationMembers.customPermissions, + joinedAt: organizationMembers.joinedAt, + email: users.email, + username: users.username, + avatarUrl: users.avatarUrl, + }) + .from(organizationMembers) + .innerJoin(users, eq(organizationMembers.userId, users.id)) + .where(eq(organizationMembers.organizationId, orgId)); + + return { data: members }; + }); + + // POST /api/organizations/:orgId/members — invite by email + app.post('/:orgId/members', { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, async (request, reply) => { + const { orgId } = request.params as { orgId: string }; + await requirePermission(request, orgId, 'org.members'); + + const { email, role } = request.body as { email: string; role: 'admin' | 'user' }; + + const user = await app.db.query.users.findFirst({ + where: eq(users.email, email), + }); + if (!user) throw AppError.notFound('User with this email not found'); + + const existing = await app.db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.organizationId, orgId), + eq(organizationMembers.userId, user.id), + ), + }); + if (existing) throw AppError.conflict('User is already a member'); + + const [member] = await app.db + .insert(organizationMembers) + .values({ + organizationId: orgId, + userId: user.id, + role, + }) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'member.add', + metadata: { userId: user.id, email, role }, + }); + + return reply.code(201).send(member); + }); + + // PATCH /api/organizations/:orgId/members/:memberId + app.patch('/:orgId/members/:memberId', { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, async (request) => { + const { orgId, memberId } = request.params as { orgId: string; memberId: string }; + await requirePermission(request, orgId, 'org.members'); + + const body = request.body as { role?: 'admin' | 'user'; customPermissions?: Record }; + + const [updated] = await app.db + .update(organizationMembers) + .set(body) + .where(and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, orgId), + )) + .returning(); + + if (!updated) throw AppError.notFound('Member not found'); + + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'member.update', + metadata: { memberId, ...body }, + }); + + return updated; + }); + + // DELETE /api/organizations/:orgId/members/:memberId + app.delete('/:orgId/members/:memberId', { schema: MemberIdParamSchema }, async (request, reply) => { + const { orgId, memberId } = request.params as { orgId: string; memberId: string }; + await requirePermission(request, orgId, 'org.members'); + + const member = await app.db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, orgId), + ), + }); + if (!member) throw AppError.notFound('Member not found'); + + // Cannot remove org owner + const org = await app.db.query.organizations.findFirst({ + where: eq(organizations.id, orgId), + }); + if (org && member.userId === org.ownerId) { + throw AppError.badRequest('Cannot remove the organization owner'); + } + + await app.db + .delete(organizationMembers) + .where(and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, orgId), + )); + + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'member.remove', + metadata: { memberId, userId: member.userId }, + }); + + return reply.code(204).send(); + }); +} diff --git a/apps/api/src/routes/organizations/schemas.ts b/apps/api/src/routes/organizations/schemas.ts new file mode 100644 index 0000000..6f38b6d --- /dev/null +++ b/apps/api/src/routes/organizations/schemas.ts @@ -0,0 +1,43 @@ +import { Type } from '@sinclair/typebox'; + +export const CreateOrgSchema = { + body: Type.Object({ + name: Type.String({ minLength: 2, maxLength: 255 }), + slug: Type.String({ minLength: 2, maxLength: 255, pattern: '^[a-z0-9-]+$' }), + }), +}; + +export const UpdateOrgSchema = { + body: Type.Object({ + name: Type.Optional(Type.String({ minLength: 2, maxLength: 255 })), + maxServers: Type.Optional(Type.Number({ minimum: 0 })), + maxNodes: Type.Optional(Type.Number({ minimum: 0 })), + }), +}; + +export const OrgIdParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + }), +}; + +export const AddMemberSchema = { + body: Type.Object({ + email: Type.String({ format: 'email' }), + role: Type.Union([Type.Literal('admin'), Type.Literal('user')]), + }), +}; + +export const UpdateMemberSchema = { + body: Type.Object({ + role: Type.Optional(Type.Union([Type.Literal('admin'), Type.Literal('user')])), + customPermissions: Type.Optional(Type.Record(Type.String(), Type.Boolean())), + }), +}; + +export const MemberIdParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + memberId: Type.String({ format: 'uuid' }), + }), +}; diff --git a/apps/api/src/routes/servers/index.ts b/apps/api/src/routes/servers/index.ts new file mode 100644 index 0000000..2384f18 --- /dev/null +++ b/apps/api/src/routes/servers/index.ts @@ -0,0 +1,280 @@ +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 }; + }); +} diff --git a/apps/api/src/routes/servers/schemas.ts b/apps/api/src/routes/servers/schemas.ts new file mode 100644 index 0000000..b5cf0fd --- /dev/null +++ b/apps/api/src/routes/servers/schemas.ts @@ -0,0 +1,46 @@ +import { Type } from '@sinclair/typebox'; + +export const ServerParamSchema = { + params: Type.Object({ + orgId: Type.String({ format: 'uuid' }), + serverId: Type.String({ format: 'uuid' }), + }), +}; + +export const CreateServerSchema = { + body: Type.Object({ + name: Type.String({ minLength: 1, maxLength: 255 }), + description: Type.Optional(Type.String()), + nodeId: Type.String({ format: 'uuid' }), + gameId: Type.String({ format: 'uuid' }), + memoryLimit: Type.Number({ minimum: 128 * 1024 * 1024 }), // min 128MB in bytes + diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB + cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })), + allocationId: Type.String({ format: 'uuid' }), + environment: Type.Optional(Type.Record(Type.String(), Type.String())), + startupOverride: Type.Optional(Type.String()), + }), +}; + +export const UpdateServerSchema = { + body: Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + description: Type.Optional(Type.String()), + memoryLimit: Type.Optional(Type.Number({ minimum: 128 * 1024 * 1024 })), + diskLimit: Type.Optional(Type.Number({ minimum: 256 * 1024 * 1024 })), + cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000 })), + environment: Type.Optional(Type.Record(Type.String(), Type.String())), + startupOverride: Type.Optional(Type.String()), + }), +}; + +export const PowerActionSchema = { + body: Type.Object({ + action: Type.Union([ + Type.Literal('start'), + Type.Literal('stop'), + Type.Literal('restart'), + Type.Literal('kill'), + ]), + }), +};