chore: initial commit for phase03

This commit is contained in:
hibna 2026-02-21 13:37:46 +03:00
parent 8eb7c90958
commit d0c20581b6
12 changed files with 1175 additions and 0 deletions

View File

@ -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;

23
apps/api/src/lib/audit.ts Normal file
View File

@ -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<string, unknown>;
},
) {
await db.insert(auditLogs).values({
organizationId: data.organizationId,
userId: request.user.sub,
serverId: data.serverId,
action: data.action,
metadata: data.metadata ?? {},
ipAddress: request.ip,
});
}

View File

@ -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<T>(data: T[], total: number, page: number, perPage: number) {
return {
data,
meta: {
page,
perPage,
total,
totalPages: Math.ceil(total / perPage),
},
};
}

View File

@ -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<string, boolean>;
}
/**
* Get the requesting user's membership in an organization.
* Super admins bypass membership checks.
*/
export async function getOrgMembership(
request: FastifyRequest,
orgId: string,
): Promise<OrgMember | 'super_admin'> {
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<string, boolean>,
};
}
/**
* 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<void> {
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');
}
}

View File

@ -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<string, unknown>;
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);
});
}

View File

@ -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' }),
}),
};

View File

@ -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<string, unknown>;
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 });
});
}

View File

@ -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 }),
}),
};

View File

@ -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<string, boolean> };
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();
});
}

View File

@ -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' }),
}),
};

View File

@ -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<string, string>;
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<string, unknown>;
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<PowerAction, string> = {
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 };
});
}

View File

@ -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'),
]),
}),
};