Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0c20581b6 | |||
| 8eb7c90958 |
@@ -36,3 +36,4 @@ build/
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
plans.md
|
||||
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev": "dotenv -e ../../.env -- tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src/"
|
||||
@@ -18,11 +18,14 @@
|
||||
"@source/database": "workspace:*",
|
||||
"@source/shared": "workspace:*",
|
||||
"argon2": "^0.41.0",
|
||||
"drizzle-orm": "^0.38.0",
|
||||
"fastify": "^5.2.0",
|
||||
"fastify-plugin": "^5.0.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"socket.io": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
+55
-3
@@ -1,26 +1,78 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
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({
|
||||
logger: {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty' }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Plugins
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(cookie);
|
||||
await app.register(dbPlugin);
|
||||
await app.register(authPlugin);
|
||||
|
||||
// Error handler
|
||||
app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.code(error.statusCode).send({
|
||||
error: error.name,
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
|
||||
// Fastify validation errors
|
||||
if (error.validation) {
|
||||
return reply.code(400).send({
|
||||
error: 'Validation Error',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error(error);
|
||||
return reply.code(500).send({
|
||||
error: 'Internal Server Error',
|
||||
message: 'An unexpected error occurred',
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.get('/api/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
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;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
|
||||
static badRequest(message: string, code?: string) {
|
||||
return new AppError(400, message, code);
|
||||
}
|
||||
|
||||
static unauthorized(message = 'Unauthorized', code?: string) {
|
||||
return new AppError(401, message, code);
|
||||
}
|
||||
|
||||
static forbidden(message = 'Forbidden', code?: string) {
|
||||
return new AppError(403, message, code);
|
||||
}
|
||||
|
||||
static notFound(message = 'Not found', code?: string) {
|
||||
return new AppError(404, message, code);
|
||||
}
|
||||
|
||||
static conflict(message: string, code?: string) {
|
||||
return new AppError(409, message, code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export interface AccessTokenPayload {
|
||||
sub: string; // user id
|
||||
email: string;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface RefreshTokenPayload {
|
||||
sub: string; // user id
|
||||
type: 'refresh';
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY = '15m';
|
||||
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||
|
||||
export function signAccessToken(app: FastifyInstance, payload: AccessTokenPayload): string {
|
||||
return app.jwt.sign(payload, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function signRefreshToken(app: FastifyInstance, payload: RefreshTokenPayload): string {
|
||||
return (app as any).jwtRefresh.sign(payload, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||
}
|
||||
|
||||
export function verifyRefreshToken(app: FastifyInstance, token: string): RefreshTokenPayload {
|
||||
return (app as any).jwtRefresh.verify(token) as RefreshTokenPayload;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import argon2 from 'argon2';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 4,
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import jwt from '@fastify/jwt';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import type { AccessTokenPayload } from '../lib/jwt.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
jwtRefresh: FastifyInstance['jwt'];
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: AccessTokenPayload;
|
||||
user: AccessTokenPayload;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (app: FastifyInstance) => {
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
const jwtRefreshSecret = process.env.JWT_REFRESH_SECRET;
|
||||
|
||||
if (!jwtSecret || !jwtRefreshSecret) {
|
||||
throw new Error('JWT_SECRET and JWT_REFRESH_SECRET environment variables are required');
|
||||
}
|
||||
|
||||
// Access token JWT
|
||||
await app.register(jwt, {
|
||||
secret: jwtSecret,
|
||||
namespace: 'jwt',
|
||||
});
|
||||
|
||||
// Refresh token JWT (separate namespace)
|
||||
await app.register(jwt, {
|
||||
secret: jwtRefreshSecret,
|
||||
namespace: 'jwtRefresh',
|
||||
});
|
||||
|
||||
// Auth decorator
|
||||
app.decorate('authenticate', async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
reply.code(401).send({ error: 'Unauthorized', message: 'Invalid or expired token' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { createDb, type Database } from '@source/database';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
db: Database;
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(async (app: FastifyInstance) => {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL environment variable is required');
|
||||
}
|
||||
|
||||
const db = createDb(databaseUrl);
|
||||
app.decorate('db', db);
|
||||
|
||||
app.log.info('Database connected');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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' }),
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { users } from '@source/database';
|
||||
import { hashPassword, verifyPassword } from '../../lib/password.js';
|
||||
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js';
|
||||
import type { AccessTokenPayload, RefreshTokenPayload } from '../../lib/jwt.js';
|
||||
import { AppError } from '../../lib/errors.js';
|
||||
import { RegisterSchema, LoginSchema } from './schemas.js';
|
||||
|
||||
const REFRESH_COOKIE_NAME = 'refresh_token';
|
||||
const REFRESH_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/api/auth',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
|
||||
};
|
||||
|
||||
export default async function authRoutes(app: FastifyInstance) {
|
||||
// POST /api/auth/register
|
||||
app.post('/register', { schema: RegisterSchema }, async (request, reply) => {
|
||||
const { email, username, password } = request.body as {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
// Check if email already exists
|
||||
const existingEmail = await app.db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
if (existingEmail) {
|
||||
throw AppError.conflict('Email already in use', 'EMAIL_TAKEN');
|
||||
}
|
||||
|
||||
// Check if username already exists
|
||||
const existingUsername = await app.db.query.users.findFirst({
|
||||
where: eq(users.username, username),
|
||||
});
|
||||
if (existingUsername) {
|
||||
throw AppError.conflict('Username already in use', 'USERNAME_TAKEN');
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const [user] = await app.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
username,
|
||||
passwordHash,
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
username: users.username,
|
||||
isSuperAdmin: users.isSuperAdmin,
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = signAccessToken(app, {
|
||||
sub: user!.id,
|
||||
email: user!.email,
|
||||
isSuperAdmin: user!.isSuperAdmin,
|
||||
});
|
||||
|
||||
const refreshToken = signRefreshToken(app, {
|
||||
sub: user!.id,
|
||||
type: 'refresh',
|
||||
});
|
||||
|
||||
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
|
||||
return reply.code(201).send({
|
||||
user: {
|
||||
id: user!.id,
|
||||
email: user!.email,
|
||||
username: user!.username,
|
||||
isSuperAdmin: user!.isSuperAdmin,
|
||||
},
|
||||
accessToken,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
app.post('/login', { schema: LoginSchema }, async (request, reply) => {
|
||||
const { email, password } = request.body as { email: string; password: string };
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(user.passwordHash, password);
|
||||
if (!isValid) {
|
||||
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const accessToken = signAccessToken(app, {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
});
|
||||
|
||||
const refreshToken = signRefreshToken(app, {
|
||||
sub: user.id,
|
||||
type: 'refresh',
|
||||
});
|
||||
|
||||
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
avatarUrl: user.avatarUrl,
|
||||
},
|
||||
accessToken,
|
||||
};
|
||||
});
|
||||
|
||||
// POST /api/auth/refresh
|
||||
app.post('/refresh', async (request, reply) => {
|
||||
const token = request.cookies[REFRESH_COOKIE_NAME];
|
||||
if (!token) {
|
||||
throw AppError.unauthorized('No refresh token', 'NO_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
let payload: RefreshTokenPayload;
|
||||
try {
|
||||
payload = verifyRefreshToken(app, token);
|
||||
} catch {
|
||||
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
throw AppError.unauthorized('Invalid refresh token', 'INVALID_REFRESH_TOKEN');
|
||||
}
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.id, payload.sub),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
throw AppError.unauthorized('User not found', 'USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Token rotation: issue new tokens
|
||||
const accessToken = signAccessToken(app, {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
isSuperAdmin: user.isSuperAdmin,
|
||||
});
|
||||
|
||||
const newRefreshToken = signRefreshToken(app, {
|
||||
sub: user.id,
|
||||
type: 'refresh',
|
||||
});
|
||||
|
||||
reply.setCookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS);
|
||||
|
||||
return { accessToken };
|
||||
});
|
||||
|
||||
// POST /api/auth/logout
|
||||
app.post('/logout', async (_request, reply) => {
|
||||
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
|
||||
const payload = request.user;
|
||||
|
||||
const user = await app.db.query.users.findFirst({
|
||||
where: eq(users.id, payload.sub),
|
||||
columns: {
|
||||
id: true,
|
||||
email: true,
|
||||
username: true,
|
||||
isSuperAdmin: true,
|
||||
avatarUrl: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw AppError.notFound('User not found');
|
||||
}
|
||||
|
||||
return { user };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
|
||||
export const RegisterSchema = {
|
||||
body: Type.Object({
|
||||
email: Type.String({ format: 'email' }),
|
||||
username: Type.String({ minLength: 3, maxLength: 100 }),
|
||||
password: Type.String({ minLength: 8, maxLength: 128 }),
|
||||
}),
|
||||
};
|
||||
|
||||
export const LoginSchema = {
|
||||
body: Type.Object({
|
||||
email: Type.String({ format: 'email' }),
|
||||
password: Type.String(),
|
||||
}),
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 }),
|
||||
}),
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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' }),
|
||||
}),
|
||||
};
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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'),
|
||||
]),
|
||||
}),
|
||||
};
|
||||
@@ -8,10 +8,10 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src/",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:seed": "tsx src/seed.ts",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:generate": "dotenv -e ../../.env -- drizzle-kit generate",
|
||||
"db:migrate": "dotenv -e ../../.env -- drizzle-kit migrate",
|
||||
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
|
||||
"db:studio": "dotenv -e ../../.env -- drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.38.0",
|
||||
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"dotenv-cli": "^8.0.0",
|
||||
"drizzle-kit": "^0.30.0",
|
||||
"tsx": "^4.19.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { pgTable, uuid, varchar, integer, boolean } from 'drizzle-orm/pg-core';
|
||||
import { nodes } from './nodes';
|
||||
import { servers } from './servers';
|
||||
|
||||
export const allocations = pgTable('allocations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
nodeId: uuid('node_id')
|
||||
.notNull()
|
||||
.references(() => nodes.id, { onDelete: 'cascade' }),
|
||||
serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }),
|
||||
ip: varchar('ip', { length: 45 }).notNull(),
|
||||
port: integer('port').notNull(),
|
||||
isDefault: boolean('is_default').default(false).notNull(),
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './users';
|
||||
export * from './organizations';
|
||||
export * from './nodes';
|
||||
export * from './allocations';
|
||||
export * from './games';
|
||||
export * from './servers';
|
||||
export * from './backups';
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
timestamp,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { organizations } from './organizations';
|
||||
import { servers } from './servers';
|
||||
|
||||
export const nodes = pgTable('nodes', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
@@ -32,14 +31,3 @@ export const nodes = pgTable('nodes', {
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const allocations = pgTable('allocations', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
nodeId: uuid('node_id')
|
||||
.notNull()
|
||||
.references(() => nodes.id, { onDelete: 'cascade' }),
|
||||
serverId: uuid('server_id').references(() => servers.id, { onDelete: 'set null' }),
|
||||
ip: varchar('ip', { length: 45 }).notNull(),
|
||||
port: integer('port').notNull(),
|
||||
isDefault: boolean('is_default').default(false).notNull(),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createDb } from './client';
|
||||
import { games } from './schema/games';
|
||||
import { users } from './schema/users';
|
||||
|
||||
async function seed() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@@ -10,8 +11,25 @@ async function seed() {
|
||||
|
||||
const db = createDb(databaseUrl);
|
||||
|
||||
console.log('Seeding games...');
|
||||
// Seed super admin
|
||||
console.log('Seeding super admin...');
|
||||
// Password: admin123 (argon2id hash)
|
||||
// In production, change this immediately after first login
|
||||
const ADMIN_PASSWORD_HASH =
|
||||
'$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$RdescudvJCsgt3ub+b+daw';
|
||||
|
||||
await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: 'admin@gamepanel.local',
|
||||
username: 'admin',
|
||||
passwordHash: ADMIN_PASSWORD_HASH,
|
||||
isSuperAdmin: true,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
// Seed games
|
||||
console.log('Seeding games...');
|
||||
await db
|
||||
.insert(games)
|
||||
.values([
|
||||
@@ -22,7 +40,7 @@ async function seed() {
|
||||
defaultPort: 25565,
|
||||
startupCommand: '/start',
|
||||
stopCommand: 'stop',
|
||||
configFiles: JSON.stringify([
|
||||
configFiles: [
|
||||
{
|
||||
path: 'server.properties',
|
||||
parser: 'properties',
|
||||
@@ -44,8 +62,8 @@ async function seed() {
|
||||
{ path: 'whitelist.json', parser: 'json' },
|
||||
{ path: 'bukkit.yml', parser: 'yaml' },
|
||||
{ path: 'spigot.yml', parser: 'yaml' },
|
||||
]),
|
||||
environmentVars: JSON.stringify([
|
||||
],
|
||||
environmentVars: [
|
||||
{ key: 'EULA', default: 'TRUE', description: 'Accept Minecraft EULA', required: true },
|
||||
{
|
||||
key: 'TYPE',
|
||||
@@ -60,7 +78,7 @@ async function seed() {
|
||||
required: true,
|
||||
},
|
||||
{ key: 'MEMORY', default: '1G', description: 'JVM memory allocation', required: false },
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'cs2',
|
||||
@@ -70,7 +88,7 @@ async function seed() {
|
||||
startupCommand:
|
||||
'./srcds_run -game csgo -console -usercon +game_type 0 +game_mode 0 +mapgroup mg_active +map de_dust2',
|
||||
stopCommand: 'quit',
|
||||
configFiles: JSON.stringify([
|
||||
configFiles: [
|
||||
{
|
||||
path: 'csgo/cfg/server.cfg',
|
||||
parser: 'keyvalue',
|
||||
@@ -84,8 +102,8 @@ async function seed() {
|
||||
],
|
||||
},
|
||||
{ path: 'csgo/cfg/autoexec.cfg', parser: 'keyvalue' },
|
||||
]),
|
||||
environmentVars: JSON.stringify([
|
||||
],
|
||||
environmentVars: [
|
||||
{
|
||||
key: 'SRCDS_TOKEN',
|
||||
default: '',
|
||||
@@ -100,7 +118,7 @@ async function seed() {
|
||||
description: 'Max players',
|
||||
required: false,
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
Generated
+35
@@ -56,9 +56,15 @@ importers:
|
||||
argon2:
|
||||
specifier: ^0.41.0
|
||||
version: 0.41.1
|
||||
drizzle-orm:
|
||||
specifier: ^0.38.0
|
||||
version: 0.38.4(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4)
|
||||
fastify:
|
||||
specifier: ^5.2.0
|
||||
version: 5.7.4
|
||||
fastify-plugin:
|
||||
specifier: ^5.0.0
|
||||
version: 5.1.0
|
||||
pino-pretty:
|
||||
specifier: ^13.0.0
|
||||
version: 13.1.3
|
||||
@@ -66,6 +72,9 @@ importers:
|
||||
specifier: ^4.8.0
|
||||
version: 4.8.3
|
||||
devDependencies:
|
||||
dotenv-cli:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
tsx:
|
||||
specifier: ^4.19.0
|
||||
version: 4.21.0
|
||||
@@ -128,6 +137,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^22.0.0
|
||||
version: 22.19.11
|
||||
dotenv-cli:
|
||||
specifier: ^8.0.0
|
||||
version: 8.0.0
|
||||
drizzle-kit:
|
||||
specifier: ^0.30.0
|
||||
version: 0.30.6
|
||||
@@ -1408,6 +1420,18 @@ packages:
|
||||
dlv@1.1.3:
|
||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||
|
||||
dotenv-cli@8.0.0:
|
||||
resolution: {integrity: sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==}
|
||||
hasBin: true
|
||||
|
||||
dotenv-expand@10.0.0:
|
||||
resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
dotenv@16.6.1:
|
||||
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
drizzle-kit@0.30.6:
|
||||
resolution: {integrity: sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==}
|
||||
hasBin: true
|
||||
@@ -3468,6 +3492,17 @@ snapshots:
|
||||
|
||||
dlv@1.1.3: {}
|
||||
|
||||
dotenv-cli@8.0.0:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
dotenv: 16.6.1
|
||||
dotenv-expand: 10.0.0
|
||||
minimist: 1.2.8
|
||||
|
||||
dotenv-expand@10.0.0: {}
|
||||
|
||||
dotenv@16.6.1: {}
|
||||
|
||||
drizzle-kit@0.30.6:
|
||||
dependencies:
|
||||
'@drizzle-team/brocli': 0.10.2
|
||||
|
||||
Reference in New Issue
Block a user