diff --git a/.env.example b/.env.example index 44cdfd5..2a6b891 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,16 @@ STRIPE_WEBHOOK_SECRET= OTEL_EXPORTER_OTLP_ENDPOINT= LOG_LEVEL=info +# --- OIDC (optional) --- +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=http://localhost:3001/api/v1/auth/oidc/callback + +# --- Notification delivery (optional) --- +NOTIFICATION_WEBHOOK_URL= +NOTIFICATION_DISCORD_WEBHOOK_URL= + # --- Service Ports (development) --- API_PORT=3001 WEB_PORT=3000 diff --git a/apps/api/package.json b/apps/api/package.json index 4106eaf..19ff0d9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,16 +21,22 @@ "@hexahost/contracts": "workspace:*", "@hexahost/database": "workspace:*", "@hexahost/dns": "workspace:*", + "@hexahost/feature-flags": "workspace:*", + "@hexahost/otel": "workspace:*", "@hexahost/integration-auth": "workspace:*", "@hexahost/metering": "workspace:*", + "@hexahost/permissions": "workspace:*", "@hexahost/scheduler": "workspace:*", + "@hexahost/server-state": "workspace:*", "@hexahost/storage": "workspace:*", + "@aws-sdk/client-s3": "^3.750.0", "@nestjs/common": "^11.0.7", "@nestjs/core": "^11.0.7", "@nestjs/platform-fastify": "^11.0.7", "@nestjs/swagger": "^11.0.3", "@nestjs/throttler": "^6.4.0", "bullmq": "^5.34.8", + "cron-parser": "^5.0.4", "fastify": "^5.2.1", "ioredis": "^5.4.2", "nestjs-pino": "^4.3.0", diff --git a/apps/api/src/abuse/abuse.controller.ts b/apps/api/src/abuse/abuse.controller.ts new file mode 100644 index 0000000..60600e0 --- /dev/null +++ b/apps/api/src/abuse/abuse.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { createAbuseReportSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { AbuseService } from './abuse.service'; + +@ApiTags('abuse') +@Controller('abuse/reports') +@UseGuards(SessionGuard) +export class AbuseController { + constructor(private readonly abuseService: AbuseService) {} + + @Post() + @ApiOperation({ summary: 'Submit an abuse report' }) + create( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(createAbuseReportSchema)) body: unknown, + ) { + return this.abuseService.create( + user.id, + body as Parameters[1], + ); + } +} diff --git a/apps/api/src/abuse/abuse.module.ts b/apps/api/src/abuse/abuse.module.ts new file mode 100644 index 0000000..6d509d4 --- /dev/null +++ b/apps/api/src/abuse/abuse.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { AbuseController } from './abuse.controller'; +import { AbuseService } from './abuse.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [AbuseController], + providers: [AbuseService], + exports: [AbuseService], +}) +export class AbuseModule {} diff --git a/apps/api/src/abuse/abuse.service.ts b/apps/api/src/abuse/abuse.service.ts new file mode 100644 index 0000000..f94a6ff --- /dev/null +++ b/apps/api/src/abuse/abuse.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; + +import type { + AbuseReport, + AbuseReportListResponse, + CreateAbuseReport, + UpdateAbuseReport, +} from '@hexahost/contracts'; +import type { AbuseReport as AbuseReportRow } from '@hexahost/database'; + +import { PrismaService } from '../prisma/prisma.service'; + +function mapReport(report: AbuseReportRow): AbuseReport { + return { + id: report.id, + reporterId: report.reporterId, + targetUserId: report.targetUserId, + serverId: report.serverId, + status: report.status, + reason: report.reason, + details: report.details, + resolution: report.resolution, + createdAt: report.createdAt.toISOString(), + updatedAt: report.updatedAt.toISOString(), + }; +} + +@Injectable() +export class AbuseService { + constructor(private readonly prisma: PrismaService) {} + + async create(reporterId: string, input: CreateAbuseReport): Promise { + const report = await this.prisma.abuseReport.create({ + data: { + reporterId, + targetUserId: input.targetUserId, + serverId: input.serverId, + reason: input.reason.trim(), + details: input.details?.trim(), + }, + }); + + return mapReport(report); + } + + async listAdmin(options?: { + status?: AbuseReport['status']; + limit?: number; + cursor?: string; + }): Promise { + const limit = Math.min(Math.max(options?.limit ?? 50, 1), 100); + + const reports = await this.prisma.abuseReport.findMany({ + where: options?.status ? { status: options.status } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(options?.cursor + ? { cursor: { id: options.cursor }, skip: 1 } + : {}), + }); + + return { + reports: reports.slice(0, limit).map(mapReport), + }; + } + + async updateAdmin(reportId: string, input: UpdateAbuseReport): Promise { + const report = await this.prisma.abuseReport.update({ + where: { id: reportId }, + data: { + status: input.status, + resolution: input.resolution, + }, + }); + + return mapReport(report); + } +} diff --git a/apps/api/src/admin/admin-abuse.controller.ts b/apps/api/src/admin/admin-abuse.controller.ts new file mode 100644 index 0000000..9742d95 --- /dev/null +++ b/apps/api/src/admin/admin-abuse.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { updateAbuseReportSchema } from '@hexahost/contracts'; +import type { AbuseReport } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; +import { AbuseService } from '../abuse/abuse.service'; + +import { assertSuperAdmin } from './admin-auth.util'; + +@ApiTags('admin') +@Controller('admin/abuse') +@UseGuards(SessionGuard) +export class AdminAbuseController { + constructor(private readonly abuseService: AbuseService) {} + + @Get() + @ApiOperation({ summary: 'List abuse reports' }) + list( + @CurrentUser() user: { roles: string[] }, + @Query('status') status?: AbuseReport['status'], + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ) { + assertSuperAdmin(user.roles); + return this.abuseService.listAdmin({ + status, + limit: limit ? Number(limit) : undefined, + cursor, + }); + } + + @Patch(':reportId') + @ApiOperation({ summary: 'Update an abuse report' }) + update( + @CurrentUser() user: { roles: string[] }, + @Param('reportId', ParseUUIDPipe) reportId: string, + @Body(new ZodValidationPipe(updateAbuseReportSchema)) body: unknown, + ) { + assertSuperAdmin(user.roles); + return this.abuseService.updateAdmin( + reportId, + body as Parameters[1], + ); + } +} diff --git a/apps/api/src/admin/admin-audit.controller.ts b/apps/api/src/admin/admin-audit.controller.ts new file mode 100644 index 0000000..b3e3760 --- /dev/null +++ b/apps/api/src/admin/admin-audit.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminAuditService } from './admin-audit.service'; + +@ApiTags('admin') +@Controller('admin/audit') +@UseGuards(SessionGuard) +export class AdminAuditController { + constructor(private readonly audit: AdminAuditService) {} + + @Get() + @ApiOperation({ summary: 'List audit events' }) + list( + @CurrentUser() user: { roles: string[] }, + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + @Query('action') action?: string, + ) { + assertSuperAdmin(user.roles); + return this.audit.listAuditEvents({ + limit: limit ? Number(limit) : undefined, + cursor, + action, + }); + } +} diff --git a/apps/api/src/admin/admin-audit.service.ts b/apps/api/src/admin/admin-audit.service.ts new file mode 100644 index 0000000..e9b4dff --- /dev/null +++ b/apps/api/src/admin/admin-audit.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminAuditService { + constructor(private readonly prisma: PrismaService) {} + + async listAuditEvents(query: { + limit?: number; + cursor?: string; + action?: string; + }): Promise<{ + items: Array<{ + id: string; + action: string; + entityType: string | null; + entityId: string | null; + metadata: unknown; + ipAddress: string | null; + createdAt: string; + user: { id: string; email: string; username: string } | null; + }>; + nextCursor: string | null; + }> { + const limit = Math.min(query.limit ?? 50, 100); + + const events = await this.prisma.auditEvent.findMany({ + where: query.action ? { action: query.action } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(query.cursor + ? { + cursor: { id: query.cursor }, + skip: 1, + } + : {}), + include: { + user: { + select: { id: true, email: true, username: true }, + }, + }, + }); + + const hasMore = events.length > limit; + const items = hasMore ? events.slice(0, limit) : events; + + return { + items: items.map((event) => ({ + id: event.id, + action: event.action, + entityType: event.entityType, + entityId: event.entityId, + metadata: event.metadata, + ipAddress: event.ipAddress, + createdAt: event.createdAt.toISOString(), + user: event.user, + })), + nextCursor: hasMore ? items[items.length - 1]?.id ?? null : null, + }; + } +} diff --git a/apps/api/src/admin/admin-auth.util.ts b/apps/api/src/admin/admin-auth.util.ts new file mode 100644 index 0000000..9c9ef84 --- /dev/null +++ b/apps/api/src/admin/admin-auth.util.ts @@ -0,0 +1,18 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { UserRole } from '@hexahost/auth'; + +export function assertSuperAdmin(roles: string[]): void { + if (!roles.includes(UserRole.SUPER_ADMIN)) { + throw new ForbiddenException('Super administrator access required'); + } +} + +export function assertPlatformRole( + roles: string[], + allowed: UserRole[], +): void { + if (!allowed.some((role) => roles.includes(role))) { + throw new ForbiddenException('Insufficient platform permissions'); + } +} diff --git a/apps/api/src/admin/admin-dashboard.controller.ts b/apps/api/src/admin/admin-dashboard.controller.ts new file mode 100644 index 0000000..dea497a --- /dev/null +++ b/apps/api/src/admin/admin-dashboard.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminDashboardService } from './admin-dashboard.service'; + +@ApiTags('admin') +@Controller('admin/dashboard') +@UseGuards(SessionGuard) +export class AdminDashboardController { + constructor(private readonly dashboard: AdminDashboardService) {} + + @Get() + @ApiOperation({ summary: 'Admin dashboard statistics' }) + stats(@CurrentUser() user: { roles: string[] }) { + assertSuperAdmin(user.roles); + return this.dashboard.getStats(); + } +} diff --git a/apps/api/src/admin/admin-dashboard.service.ts b/apps/api/src/admin/admin-dashboard.service.ts new file mode 100644 index 0000000..da7b29f --- /dev/null +++ b/apps/api/src/admin/admin-dashboard.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminDashboardService { + constructor(private readonly prisma: PrismaService) {} + + async getStats() { + const [ + activeUsers, + totalServers, + runningServers, + queuedServers, + onlineNodes, + failedJobs24h, + openAbuseReports, + openTickets, + ] = await Promise.all([ + this.prisma.user.count({ where: { status: 'ACTIVE' } }), + this.prisma.gameServer.count({ + where: { status: { not: 'DELETED' } }, + }), + this.prisma.gameServer.count({ where: { status: 'RUNNING' } }), + this.prisma.serverStartQueueEntry.count({ where: { status: 'QUEUED' } }), + this.prisma.gameNode.count({ where: { status: 'ONLINE' } }), + this.prisma.jobRecord.count({ + where: { + status: 'FAILED', + createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }, + }, + }), + this.prisma.abuseReport.count({ where: { status: 'OPEN' } }), + this.prisma.supportTicket.count({ + where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING'] } }, + }), + ]); + + return { + activeUsers, + totalServers, + runningServers, + queueLength: queuedServers, + onlineNodes, + failedJobs24h, + openAbuseReports, + openTickets, + generatedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/api/src/admin/admin-jobs.controller.ts b/apps/api/src/admin/admin-jobs.controller.ts new file mode 100644 index 0000000..99e54ae --- /dev/null +++ b/apps/api/src/admin/admin-jobs.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminJobsService } from './admin-jobs.service'; + +@ApiTags('admin') +@Controller('admin/jobs') +@UseGuards(SessionGuard) +export class AdminJobsController { + constructor(private readonly jobs: AdminJobsService) {} + + @Get() + @ApiOperation({ summary: 'List background jobs' }) + list( + @CurrentUser() user: { roles: string[] }, + @Query('limit') limit?: string, + @Query('status') status?: string, + @Query('queue') queue?: string, + ) { + assertSuperAdmin(user.roles); + return this.jobs.listJobs({ + limit: limit ? Number(limit) : undefined, + status, + queue, + }); + } +} diff --git a/apps/api/src/admin/admin-jobs.service.ts b/apps/api/src/admin/admin-jobs.service.ts new file mode 100644 index 0000000..6245563 --- /dev/null +++ b/apps/api/src/admin/admin-jobs.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminJobsService { + constructor(private readonly prisma: PrismaService) {} + + async listJobs(query: { limit?: number; status?: string; queue?: string }): Promise<{ + jobs: Array<{ + id: string; + queue: string; + jobName: string; + status: string; + attempts: number; + error: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + }>; + }> { + const limit = Math.min(query.limit ?? 50, 100); + + const jobs = await this.prisma.jobRecord.findMany({ + where: { + ...(query.status ? { status: query.status as never } : {}), + ...(query.queue ? { queue: query.queue } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + return { + jobs: jobs.map((job) => ({ + id: job.id, + queue: job.queue, + jobName: job.jobName, + status: job.status, + attempts: job.attempts, + error: job.error, + startedAt: job.startedAt?.toISOString() ?? null, + completedAt: job.completedAt?.toISOString() ?? null, + createdAt: job.createdAt.toISOString(), + })), + }; + } +} diff --git a/apps/api/src/admin/admin-users.controller.ts b/apps/api/src/admin/admin-users.controller.ts new file mode 100644 index 0000000..125e64b --- /dev/null +++ b/apps/api/src/admin/admin-users.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminUsersService } from './admin-users.service'; + +@ApiTags('admin') +@Controller('admin/users') +@UseGuards(SessionGuard) +export class AdminUsersController { + constructor(private readonly users: AdminUsersService) {} + + @Get() + @ApiOperation({ summary: 'Search users' }) + search( + @CurrentUser() user: { roles: string[] }, + @Query('q') q?: string, + @Query('limit') limit?: string, + ) { + assertSuperAdmin(user.roles); + return this.users.searchUsers({ + q, + limit: limit ? Number(limit) : undefined, + }); + } + + @Post(':id/suspend') + @ApiOperation({ summary: 'Suspend a user account' }) + suspend( + @CurrentUser() user: { roles: string[] }, + @Param('id', ParseUUIDPipe) id: string, + ) { + assertSuperAdmin(user.roles); + return this.users.suspendUser(id); + } + + @Post(':id/unsuspend') + @ApiOperation({ summary: 'Unsuspend a user account' }) + unsuspend( + @CurrentUser() user: { roles: string[] }, + @Param('id', ParseUUIDPipe) id: string, + ) { + assertSuperAdmin(user.roles); + return this.users.unsuspendUser(id); + } +} diff --git a/apps/api/src/admin/admin-users.service.ts b/apps/api/src/admin/admin-users.service.ts new file mode 100644 index 0000000..d72276c --- /dev/null +++ b/apps/api/src/admin/admin-users.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminUsersService { + constructor(private readonly prisma: PrismaService) {} + + async searchUsers(query: { q?: string; limit?: number }): Promise<{ + users: Array<{ + id: string; + email: string; + username: string; + displayName: string | null; + status: string; + roles: string[]; + twoFactorEnabled: boolean; + sessionCount: number; + serverCount: number; + createdAt: string; + }>; + }> { + const limit = Math.min(query.limit ?? 25, 100); + const q = query.q?.trim(); + + const users = await this.prisma.user.findMany({ + where: q + ? { + OR: [ + { email: { contains: q, mode: 'insensitive' } }, + { username: { contains: q, mode: 'insensitive' } }, + ], + } + : undefined, + orderBy: { createdAt: 'desc' }, + take: limit, + include: { + platformRoles: { + include: { role: true }, + }, + totpCredential: { select: { enabledAt: true } }, + _count: { select: { sessions: true, gameServers: true } }, + }, + }); + + return { + users: users.map((user) => ({ + id: user.id, + email: user.email, + username: user.username, + displayName: user.displayName, + status: user.status, + roles: user.platformRoles.map((entry) => entry.role.name), + twoFactorEnabled: Boolean(user.totpCredential?.enabledAt), + sessionCount: user._count.sessions, + serverCount: user._count.gameServers, + createdAt: user.createdAt.toISOString(), + })), + }; + } + + async suspendUser(userId: string, reason?: string): Promise<{ id: string; status: string }> { + return this.prisma.user.update({ + where: { id: userId }, + data: { status: 'SUSPENDED' }, + select: { id: true, status: true }, + }); + } + + async unsuspendUser(userId: string): Promise<{ id: string; status: string }> { + return this.prisma.user.update({ + where: { id: userId }, + data: { status: 'ACTIVE', failedLoginAttempts: 0, lockedUntil: null }, + select: { id: true, status: true }, + }); + } +} diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index 3771fc5..fe4ead5 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -1,13 +1,36 @@ import { Module } from '@nestjs/common'; +import { AbuseModule } from '../abuse/abuse.module'; import { AuthModule } from '../auth/auth.module'; +import { AdminAbuseController } from './admin-abuse.controller'; +import { AdminAuditController } from './admin-audit.controller'; +import { AdminAuditService } from './admin-audit.service'; +import { AdminDashboardController } from './admin-dashboard.controller'; +import { AdminDashboardService } from './admin-dashboard.service'; +import { AdminJobsController } from './admin-jobs.controller'; +import { AdminJobsService } from './admin-jobs.service'; import { AdminNodesController } from './admin-nodes.controller'; import { AdminNodesService } from './admin-nodes.service'; +import { AdminUsersController } from './admin-users.controller'; +import { AdminUsersService } from './admin-users.service'; @Module({ - imports: [AuthModule], - controllers: [AdminNodesController], - providers: [AdminNodesService], + imports: [AuthModule, AbuseModule], + controllers: [ + AdminNodesController, + AdminDashboardController, + AdminAuditController, + AdminJobsController, + AdminUsersController, + AdminAbuseController, + ], + providers: [ + AdminNodesService, + AdminDashboardService, + AdminAuditService, + AdminJobsService, + AdminUsersService, + ], }) export class AdminModule {} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cda1273..35e278b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,11 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { AbuseModule } from './abuse/abuse.module'; +import { ComplianceModule } from './compliance/compliance.module'; +import { NotificationsModule } from './notifications/notifications.module'; +import { FeatureFlagsModule } from './feature-flags/feature-flags.module'; +import { OrganizationsModule } from './organizations/organizations.module'; +import { SupportModule } from './support/support.module'; import { RequestIdMiddleware } from './common/middleware/request-id.middleware'; import { HealthModule } from './health/health.module'; import { PrismaModule } from './prisma/prisma.module'; @@ -13,10 +19,14 @@ import { EdgeModule } from './edge/edge.module'; import { ServersModule } from './servers/servers.module'; import { AppConfigModule } from './config/app-config.module'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { LoggerModule } from 'nestjs-pino'; +import { CsrfModule } from './common/csrf/csrf.module'; +import { CsrfGuard } from './common/csrf/csrf.guard'; +import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor'; + @Module({ imports: [ LoggerModule.forRoot({ @@ -51,12 +61,27 @@ import { LoggerModule } from 'nestjs-pino'; ServersModule, NodesModule, AdminModule, + NotificationsModule, + ComplianceModule, + FeatureFlagsModule, + OrganizationsModule, + SupportModule, + AbuseModule, + CsrfModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard, }, + { + provide: APP_GUARD, + useClass: CsrfGuard, + }, + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 259342d..b995de6 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Body, Controller, + Get, HttpCode, HttpStatus, Post, @@ -221,4 +222,35 @@ export class AuthController { request.ip, ); } + + @Public() + @Get('oidc/login') + @ApiOperation({ summary: 'Redirect to OIDC provider (optional)' }) + oidcLogin(@Res() reply: FastifyReply) { + const issuer = process.env['OIDC_ISSUER']; + const clientId = process.env['OIDC_CLIENT_ID']; + const redirectUri = process.env['OIDC_REDIRECT_URI']; + if (!issuer || !clientId || !redirectUri) { + throw new BadRequestException('OIDC is not configured'); + } + const url = new URL(`${issuer.replace(/\/$/, '')}/authorize`); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid email profile'); + return reply.redirect(url.toString(), 302); + } + + @Public() + @Get('oidc/callback') + @ApiOperation({ summary: 'OIDC callback (optional)' }) + async oidcCallback( + @Query('code') code: string | undefined, + @Res() reply: FastifyReply, + ) { + if (!code) { + throw new BadRequestException('Missing authorization code'); + } + return this.authService.completeOidcLogin(code, reply); + } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 69bb756..e01b479 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -50,6 +50,13 @@ import { SsoService } from './sso.service'; }, }, ], - exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService], + exports: [ + AuthService, + SessionService, + SessionGuard, + AuditService, + SsoService, + NOTIFICATIONS_QUEUE, + ], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 3a8858a..60d127b 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -712,4 +712,77 @@ export class AuthService { return false; } + + async completeOidcLogin(code: string, reply: FastifyReply): Promise { + const issuer = process.env['OIDC_ISSUER']; + const clientId = process.env['OIDC_CLIENT_ID']; + const clientSecret = process.env['OIDC_CLIENT_SECRET']; + const redirectUri = process.env['OIDC_REDIRECT_URI']; + if (!issuer || !clientId || !clientSecret || !redirectUri) { + throw new BadRequestException('OIDC is not configured'); + } + + const tokenResponse = await fetch(`${issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + }), + }); + + if (!tokenResponse.ok) { + throw new UnauthorizedException('OIDC token exchange failed'); + } + + const tokenJson = (await tokenResponse.json()) as { access_token?: string }; + if (!tokenJson.access_token) { + throw new UnauthorizedException('OIDC token missing'); + } + + const userInfoResponse = await fetch(`${issuer.replace(/\/$/, '')}/userinfo`, { + headers: { Authorization: `Bearer ${tokenJson.access_token}` }, + }); + if (!userInfoResponse.ok) { + throw new UnauthorizedException('OIDC userinfo failed'); + } + + const profile = (await userInfoResponse.json()) as { + email?: string; + sub?: string; + name?: string; + }; + if (!profile.email) { + throw new BadRequestException('OIDC profile has no email'); + } + + let user = await this.prisma.user.findUnique({ + where: { email: profile.email }, + }); + if (!user) { + const username = generateUsernameFromEmail(profile.email); + const passwordHash = await hashPassword(generateSecureToken(32)); + user = await this.prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + email: profile.email!, + username, + displayName: profile.name ?? username, + emailVerifiedAt: new Date(), + }, + }); + await tx.userCredential.create({ + data: { userId: created.id, passwordHash }, + }); + return created; + }); + } + + const { token, expiresAt } = await this.sessionService.createSession(user.id); + this.sessionService.setSessionCookie(reply, token, expiresAt); + reply.redirect(`${this.config.APP_URL}/dashboard`, 302); + } } diff --git a/apps/api/src/catalog/catalog.controller.ts b/apps/api/src/catalog/catalog.controller.ts index 6a6a97c..60a5dda 100644 --- a/apps/api/src/catalog/catalog.controller.ts +++ b/apps/api/src/catalog/catalog.controller.ts @@ -8,6 +8,19 @@ import { PrismaService } from '../prisma/prisma.service'; import { CatalogService } from './catalog.service'; +const SOFTWARE_FAMILIES = [ + 'VANILLA', + 'PAPER', + 'PURPUR', + 'FABRIC', + 'FORGE', + 'NEOFORGE', + 'QUILT', + 'BEDROCK_DEDICATED', +] as const; + +const MINECRAFT_VERSIONS = ['1.21.1', '1.21', '1.20.6', '1.20.4']; + @ApiTags('catalog') @Controller('catalog') @UseGuards(SessionGuard) @@ -17,6 +30,55 @@ export class CatalogController { private readonly prisma: PrismaService, ) {} + @Get('software') + @ApiOperation({ summary: 'List supported server software families' }) + listSoftware() { + return { + families: SOFTWARE_FAMILIES.map((family) => ({ id: family, name: family })), + }; + } + + @Get('versions') + @ApiOperation({ summary: 'List supported Minecraft versions' }) + async listVersions(@Query('edition') edition?: string) { + const dbVersions = await this.prisma.minecraftVersion.findMany({ + where: edition ? { edition: edition as 'JAVA' | 'BEDROCK', isActive: true } : { isActive: true }, + orderBy: { version: 'desc' }, + }); + + if (dbVersions.length > 0) { + return { versions: dbVersions.map((entry) => entry.version) }; + } + + return { versions: MINECRAFT_VERSIONS }; + } + + @Get('regions') + @ApiOperation({ summary: 'List available deployment regions' }) + async listRegions(): Promise<{ + regions: Array<{ + id: string; + name: string; + hostname: string; + status: string; + }>; + }> { + const nodes = await this.prisma.gameNode.findMany({ + where: { status: { in: ['ONLINE', 'OFFLINE'] } }, + select: { id: true, name: true, hostname: true, status: true }, + orderBy: { name: 'asc' }, + }); + + return { + regions: nodes.map((node) => ({ + id: node.id, + name: node.name, + hostname: node.hostname, + status: node.status, + })), + }; + } + @Get('projects') @ApiOperation({ summary: 'Search Modrinth projects' }) search(@Query() query: Record) { @@ -64,4 +126,17 @@ export class CatalogController { resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT', ); } + + @Get('curseforge/projects') + @ApiOperation({ summary: 'Search CurseForge mods (requires API key)' }) + searchCurseForge(@Query('q') q?: string, @Query('limit') limit?: string) { + if (!q?.trim()) { + throw new BadRequestException('q is required'); + } + const parsedLimit = limit ? Number.parseInt(limit, 10) : 20; + return this.catalogService.searchCurseForge({ + query: q.trim(), + limit: Number.isFinite(parsedLimit) ? parsedLimit : 20, + }); + } } diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts index 05900fe..3b21594 100644 --- a/apps/api/src/catalog/catalog.service.ts +++ b/apps/api/src/catalog/catalog.service.ts @@ -9,6 +9,7 @@ import { isVersionCompatible, resolveModrinthLoader, resolveProjectType, + searchCurseForgeMods, searchModrinthProjects, type SoftwareFamily, } from '@hexahost/catalog'; @@ -78,4 +79,26 @@ export class CatalogService { })), }; } + + async searchCurseForge(input: { query: string; limit?: number }) { + try { + const result = await searchCurseForgeMods({ + searchFilter: input.query, + pageSize: input.limit ?? 20, + }); + return { + provider: 'curseforge' as const, + projects: result.data.map((project) => ({ + id: String(project.id), + slug: project.slug, + name: project.name, + description: project.summary, + downloads: project.downloadCount, + iconUrl: project.logo?.thumbnailUrl ?? null, + })), + }; + } catch { + throw new BadRequestException('CurseForge integration is not configured'); + } + } } diff --git a/apps/api/src/common/csrf/csrf.controller.ts b/apps/api/src/common/csrf/csrf.controller.ts new file mode 100644 index 0000000..a6caae4 --- /dev/null +++ b/apps/api/src/common/csrf/csrf.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Res, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply } from 'fastify'; +import { SkipThrottle } from '@nestjs/throttler'; + +import { CsrfService } from './csrf.service'; + +@ApiTags('auth') +@Controller('auth') +@SkipThrottle() +export class CsrfController { + constructor(private readonly csrf: CsrfService) {} + + @Get('csrf') + @ApiOperation({ summary: 'Issue a CSRF token for browser clients' }) + issue(@Res({ passthrough: true }) reply: FastifyReply): { token: string } { + const token = this.csrf.issueToken(reply); + return { token }; + } +} diff --git a/apps/api/src/common/csrf/csrf.guard.ts b/apps/api/src/common/csrf/csrf.guard.ts new file mode 100644 index 0000000..3a06c45 --- /dev/null +++ b/apps/api/src/common/csrf/csrf.guard.ts @@ -0,0 +1,19 @@ +import { + CanActivate, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; + +import { CsrfService } from './csrf.service'; + +@Injectable() +export class CsrfGuard implements CanActivate { + constructor(private readonly csrf: CsrfService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + this.csrf.validate(request); + return true; + } +} diff --git a/apps/api/src/common/csrf/csrf.module.ts b/apps/api/src/common/csrf/csrf.module.ts new file mode 100644 index 0000000..6aaf7ea --- /dev/null +++ b/apps/api/src/common/csrf/csrf.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AppConfigModule } from '../../config/app-config.module'; + +import { CsrfController } from './csrf.controller'; +import { CsrfGuard } from './csrf.guard'; +import { CsrfService } from './csrf.service'; + +@Module({ + imports: [AppConfigModule], + controllers: [CsrfController], + providers: [CsrfService, CsrfGuard], + exports: [CsrfService, CsrfGuard], +}) +export class CsrfModule {} diff --git a/apps/api/src/common/csrf/csrf.service.ts b/apps/api/src/common/csrf/csrf.service.ts new file mode 100644 index 0000000..5147484 --- /dev/null +++ b/apps/api/src/common/csrf/csrf.service.ts @@ -0,0 +1,77 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +import type { AppConfig } from '@hexahost/config'; + +import { APP_CONFIG } from '../../config/config.constants'; + +const CSRF_COOKIE = 'hgc_csrf'; +const CSRF_HEADER = 'x-csrf-token'; +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +@Injectable() +export class CsrfService { + constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {} + + issueToken(reply: FastifyReply): string { + const token = randomBytes(32).toString('base64url'); + const signed = this.sign(token); + + reply.setCookie(CSRF_COOKIE, signed, { + httpOnly: false, + sameSite: 'lax', + secure: this.config.APP_ENV === 'production', + path: '/', + }); + + return token; + } + + validate(request: FastifyRequest): void { + const method = request.method.toUpperCase(); + + if (SAFE_METHODS.has(method)) { + return; + } + + const path = request.url.split('?')[0] ?? ''; + + if ( + path.startsWith('/api/v1/integrations/') || + path.startsWith('/api/v1/internal/') || + path.startsWith('/api/v1/webhooks/') || + path.startsWith('/api/v1/health/') + ) { + return; + } + + const cookieToken = this.readSignedCookie(request); + const headerToken = request.headers[CSRF_HEADER]; + + if (typeof headerToken !== 'string' || !cookieToken) { + throw new UnauthorizedException('CSRF token missing'); + } + + const expected = this.sign(headerToken); + const actual = cookieToken; + + if ( + expected.length !== actual.length || + !timingSafeEqual(Buffer.from(expected), Buffer.from(actual)) + ) { + throw new UnauthorizedException('CSRF token invalid'); + } + } + + private readSignedCookie(request: FastifyRequest): string | null { + const cookies = request.cookies as Record; + return cookies[CSRF_COOKIE] ?? null; + } + + private sign(token: string): string { + return createHmac('sha256', this.config.SESSION_SECRET) + .update(token) + .digest('base64url'); + } +} diff --git a/apps/api/src/common/interceptors/idempotency.interceptor.ts b/apps/api/src/common/interceptors/idempotency.interceptor.ts new file mode 100644 index 0000000..0e769f9 --- /dev/null +++ b/apps/api/src/common/interceptors/idempotency.interceptor.ts @@ -0,0 +1,71 @@ +import { + BadRequestException, + CallHandler, + ExecutionContext, + Inject, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, of } from 'rxjs'; +import Redis from 'ioredis'; + +import { REDIS_CLIENT } from '../../servers/servers.service'; + +const IDEMPOTENCY_HEADER = 'idempotency-key'; +const TTL_SECONDS = 86_400; + +@Injectable() +export class IdempotencyInterceptor implements NestInterceptor { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest<{ + method?: string; + headers?: Record; + user?: { id?: string }; + url?: string; + }>(); + + if (!request.method || !['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) { + return next.handle(); + } + + const rawKey = request.headers?.[IDEMPOTENCY_HEADER]; + const key = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!key || key.length < 8) { + return next.handle(); + } + + const userId = request.user?.id ?? 'anonymous'; + const cacheKey = `idempotency:panel:${userId}:${key}:${request.method}:${request.url ?? ''}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + return of(JSON.parse(cached) as unknown); + } + + return new Observable((subscriber) => { + next.handle().subscribe({ + next: (value) => { + void this.redis + .set(cacheKey, JSON.stringify(value), 'EX', TTL_SECONDS) + .catch(() => undefined); + subscriber.next(value); + subscriber.complete(); + }, + error: (error: unknown) => subscriber.error(error), + }); + }); + } +} + +export function assertIdempotencyKey(headers: Record): string { + const raw = headers[IDEMPOTENCY_HEADER]; + const key = Array.isArray(raw) ? raw[0] : raw; + if (typeof key !== 'string' || key.length < 8) { + throw new BadRequestException('Idempotency-Key header is required'); + } + return key; +} diff --git a/apps/api/src/compliance/compliance.controller.ts b/apps/api/src/compliance/compliance.controller.ts new file mode 100644 index 0000000..0e9e607 --- /dev/null +++ b/apps/api/src/compliance/compliance.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { requestAccountDeletionSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { ComplianceService } from './compliance.service'; + +@ApiTags('account') +@Controller('account/compliance') +@UseGuards(SessionGuard) +export class ComplianceController { + constructor(private readonly complianceService: ComplianceService) {} + + @Post('export') + requestExport(@CurrentUser() user: { id: string }) { + return this.complianceService.requestDataExport(user.id); + } + + @Get('export') + listExports(@CurrentUser() user: { id: string }) { + return this.complianceService.listDataExports(user.id); + } + + @Get('export/:exportId/download') + downloadExport( + @CurrentUser() user: { id: string }, + @Param('exportId', ParseUUIDPipe) exportId: string, + ) { + return this.complianceService.getExportDownload(user.id, exportId); + } + + @Post('deletion') + requestDeletion( + @CurrentUser() user: { id: string; email: string }, + @Body(new ZodValidationPipe(requestAccountDeletionSchema)) body: unknown, + ) { + const parsed = body as { confirmEmail: string }; + return this.complianceService.requestAccountDeletion(user.id, parsed.confirmEmail); + } + + @Get('deletion') + deletionStatus(@CurrentUser() user: { id: string }) { + return this.complianceService.getAccountDeletionStatus(user.id); + } + + @Delete('deletion') + cancelDeletion(@CurrentUser() user: { id: string }) { + return this.complianceService.cancelAccountDeletion(user.id); + } +} diff --git a/apps/api/src/compliance/compliance.module.ts b/apps/api/src/compliance/compliance.module.ts new file mode 100644 index 0000000..2774730 --- /dev/null +++ b/apps/api/src/compliance/compliance.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ServersModule } from '../servers/servers.module'; + +import { ComplianceController } from './compliance.controller'; +import { ComplianceService } from './compliance.service'; + +@Module({ + imports: [PrismaModule, AuthModule, ServersModule], + controllers: [ComplianceController], + providers: [ComplianceService], + exports: [ComplianceService], +}) +export class ComplianceModule {} diff --git a/apps/api/src/compliance/compliance.service.ts b/apps/api/src/compliance/compliance.service.ts new file mode 100644 index 0000000..a9097ff --- /dev/null +++ b/apps/api/src/compliance/compliance.service.ts @@ -0,0 +1,400 @@ +import { randomUUID } from 'node:crypto'; + +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Queue } from 'bullmq'; +import type Redis from 'ioredis'; + +import { getConfig } from '@hexahost/config'; +import type { GameServerStatus } from '@hexahost/server-state'; +import { ALLOWED_TRANSITIONS } from '@hexahost/server-state'; +import { + createPresignedGetUrl, + createS3Client, + loadStorageConfig, +} from '@hexahost/storage'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + REDIS_CLIENT, + SERVER_LIFECYCLE_QUEUE, +} from '../servers/servers.service'; + +const DELETION_GRACE_DAYS = 14; +const EXPORT_TTL_DAYS = 7; +const EXPORT_BLOB_PREFIX = 'export:blob:'; + +function isStorageConfigured(): boolean { + return Boolean(process.env['S3_ENDPOINT']?.trim()); +} + +function exportObjectKey(userId: string, exportId: string): string { + return `users/${userId}/exports/${exportId}.json`; +} + +@Injectable() +export class ComplianceService { + constructor( + private readonly prisma: PrismaService, + @Inject(REDIS_CLIENT) private readonly redis: Redis, + @Inject(SERVER_LIFECYCLE_QUEUE) + private readonly lifecycleQueue: Queue, + ) {} + + async requestDataExport(userId: string) { + const existing = await this.prisma.dataExportRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'PROCESSING', 'READY'] } }, + orderBy: { createdAt: 'desc' }, + }); + if (existing) { + return this.mapExport(existing); + } + + const created = await this.prisma.dataExportRequest.create({ + data: { userId, status: 'PENDING' }, + }); + + void this.processDataExport(created.id).catch(() => undefined); + + return this.mapExport(created); + } + + async listDataExports(userId: string) { + const rows = await this.prisma.dataExportRequest.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 10, + }); + return { exports: rows.map((row) => this.mapExport(row)) }; + } + + async getExportDownload(userId: string, exportId: string) { + const row = await this.prisma.dataExportRequest.findFirst({ + where: { id: exportId, userId }, + }); + if (!row || row.status !== 'READY') { + throw new NotFoundException('Export not available'); + } + if (row.expiresAt && row.expiresAt <= new Date()) { + throw new NotFoundException('Export has expired'); + } + + if (row.downloadUrl?.startsWith('s3://')) { + const [, bucket, ...keyParts] = row.downloadUrl.split('/'); + const key = keyParts.join('/'); + const config = loadStorageConfig(); + const client = createS3Client(config); + const url = await createPresignedGetUrl(client, bucket!, key, 3600); + return { url, expiresInSeconds: 3600 }; + } + + const blob = await this.redis.get(`${EXPORT_BLOB_PREFIX}${exportId}`); + if (!blob) { + throw new NotFoundException('Export data not found'); + } + + return { + contentType: 'application/json', + data: JSON.parse(blob) as unknown, + }; + } + + async requestAccountDeletion(userId: string, confirmEmail: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.email !== confirmEmail) { + throw new BadRequestException('Email confirmation does not match'); + } + + const scheduledFor = new Date(); + scheduledFor.setDate(scheduledFor.getDate() + DELETION_GRACE_DAYS); + + const existing = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } }, + }); + if (existing) { + return this.mapDeletion(existing); + } + + const created = await this.prisma.accountDeletionRequest.create({ + data: { + userId, + status: 'SCHEDULED', + scheduledFor, + }, + }); + return this.mapDeletion(created); + } + + async cancelAccountDeletion(userId: string) { + const pending = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } }, + }); + if (!pending) { + throw new NotFoundException('No pending deletion request'); + } + const updated = await this.prisma.accountDeletionRequest.update({ + where: { id: pending.id }, + data: { status: 'CANCELLED' }, + }); + return this.mapDeletion(updated); + } + + async getAccountDeletionStatus(userId: string) { + const pending = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } }, + orderBy: { createdAt: 'desc' }, + }); + return { request: pending ? this.mapDeletion(pending) : null }; + } + + async processPendingExports(limit = 10): Promise { + const pending = await this.prisma.dataExportRequest.findMany({ + where: { status: 'PENDING' }, + take: limit, + }); + + for (const row of pending) { + await this.processDataExport(row.id); + } + + return pending.length; + } + + async processDueDeletions(limit = 10): Promise { + const due = await this.prisma.accountDeletionRequest.findMany({ + where: { status: 'SCHEDULED', scheduledFor: { lte: new Date() } }, + take: limit, + }); + + for (const row of due) { + await this.executeAccountDeletion(row.id); + } + + return due.length; + } + + async processDataExport(exportId: string): Promise { + const row = await this.prisma.dataExportRequest.findUnique({ + where: { id: exportId }, + }); + if (!row || !['PENDING', 'PROCESSING'].includes(row.status)) { + return; + } + + await this.prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'PROCESSING' }, + }); + + try { + const payload = await this.collectUserExportData(row.userId); + const json = JSON.stringify(payload, null, 2); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + EXPORT_TTL_DAYS); + + let downloadUrl: string; + + if (isStorageConfigured()) { + const config = loadStorageConfig(); + const client = createS3Client(config); + const key = exportObjectKey(row.userId, exportId); + await client.send( + new PutObjectCommand({ + Bucket: config.S3_BUCKET, + Key: key, + Body: json, + ContentType: 'application/json', + }), + ); + downloadUrl = `s3://${config.S3_BUCKET}/${key}`; + } else { + await this.redis.set( + `${EXPORT_BLOB_PREFIX}${exportId}`, + json, + 'EX', + EXPORT_TTL_DAYS * 24 * 60 * 60, + ); + const config = getConfig(); + downloadUrl = `${config.API_URL}/account/compliance/export/${exportId}/download`; + } + + await this.prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { + status: 'READY', + downloadUrl, + expiresAt, + completedAt: new Date(), + }, + }); + } catch { + await this.prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'FAILED' }, + }); + } + } + + async executeAccountDeletion(requestId: string): Promise { + const request = await this.prisma.accountDeletionRequest.findUnique({ + where: { id: requestId }, + }); + if (!request || request.status !== 'SCHEDULED') { + return; + } + + const userId = request.userId; + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.status === 'DELETED') { + await this.prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }); + return; + } + + const servers = await this.prisma.gameServer.findMany({ + where: { userId, status: { not: 'DELETED' } }, + }); + + for (const server of servers) { + if (server.status === 'RUNNING' || server.status === 'STARTING') { + continue; + } + + if (ALLOWED_TRANSITIONS[server.status as GameServerStatus]?.includes('DELETING')) { + const correlationId = randomUUID(); + await this.prisma.gameServer.update({ + where: { id: server.id }, + data: { status: 'DELETING' }, + }); + await this.lifecycleQueue.add( + 'delete-server', + { serverId: server.id, action: 'delete', correlationId }, + { jobId: correlationId }, + ); + } + } + + const anonymizedId = randomUUID().slice(0, 8); + await this.prisma.$transaction([ + this.prisma.userSession.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + this.prisma.user.update({ + where: { id: userId }, + data: { + email: `deleted-${anonymizedId}@deleted.invalid`, + username: `deleted-${anonymizedId}`, + displayName: null, + status: 'DELETED', + }, + }), + this.prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }), + ]); + } + + private async collectUserExportData(userId: string) { + const [user, servers, auditEvents] = await Promise.all([ + this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + displayName: true, + status: true, + emailVerifiedAt: true, + createdAt: true, + updatedAt: true, + }, + }), + this.prisma.gameServer.findMany({ + where: { userId }, + select: { + id: true, + name: true, + status: true, + edition: true, + softwareFamily: true, + minecraftVersion: true, + ramMb: true, + hostPort: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.auditEvent.findMany({ + where: { userId }, + select: { + action: true, + entityType: true, + entityId: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: 500, + }), + ]); + + const actionCounts = auditEvents.reduce>((acc, event) => { + acc[event.action] = (acc[event.action] ?? 0) + 1; + return acc; + }, {}); + + return { + exportedAt: new Date().toISOString(), + profile: user, + servers, + auditSummary: { + totalEvents: auditEvents.length, + actionCounts, + recentEvents: auditEvents.slice(0, 50), + }, + }; + } + + private mapExport(row: { + id: string; + status: string; + downloadUrl: string | null; + expiresAt: Date | null; + completedAt: Date | null; + createdAt: Date; + }) { + return { + id: row.id, + status: row.status as 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'EXPIRED', + downloadUrl: row.downloadUrl, + expiresAt: row.expiresAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } + + private mapDeletion(row: { + id: string; + status: string; + scheduledFor: Date; + completedAt: Date | null; + createdAt: Date; + }) { + return { + id: row.id, + status: row.status as 'PENDING' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED', + scheduledFor: row.scheduledFor.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/feature-flags/feature-flags.controller.ts b/apps/api/src/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..a0c4b69 --- /dev/null +++ b/apps/api/src/feature-flags/feature-flags.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FeatureFlagService } from '@hexahost/feature-flags'; + +import { Public } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { FEATURE_FLAG_SERVICE } from './feature-flags.module'; + +@ApiTags('system') +@Controller('feature-flags') +export class FeatureFlagsController { + constructor( + @Inject(FEATURE_FLAG_SERVICE) private readonly flags: FeatureFlagService, + ) {} + + @Public() + @Get() + @ApiOperation({ summary: 'List public feature flags' }) + async list() { + const flags = await this.flags.listFlags(); + return { + flags: flags.map((flag) => ({ + key: flag.key, + enabled: flag.enabled, + })), + }; + } + + @Get('admin') + @UseGuards(SessionGuard) + @ApiOperation({ summary: 'List feature flags with metadata (authenticated)' }) + async listDetailed() { + return { flags: await this.flags.listFlags() }; + } +} diff --git a/apps/api/src/feature-flags/feature-flags.module.ts b/apps/api/src/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..bfa7f05 --- /dev/null +++ b/apps/api/src/feature-flags/feature-flags.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; + +import { FeatureFlagService } from '@hexahost/feature-flags'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PrismaService } from '../prisma/prisma.service'; + +import { FeatureFlagsController } from './feature-flags.controller'; + +export const FEATURE_FLAG_SERVICE = Symbol('FEATURE_FLAG_SERVICE'); + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [FeatureFlagsController], + providers: [ + { + provide: FEATURE_FLAG_SERVICE, + useFactory: (prisma: PrismaService) => new FeatureFlagService(prisma), + inject: [PrismaService], + }, + ], + exports: [FEATURE_FLAG_SERVICE], +}) +export class FeatureFlagsModule {} diff --git a/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts b/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts index 73434fa..e5bb8be 100644 --- a/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts +++ b/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts @@ -109,6 +109,21 @@ export class WhmcsIntegrationController { ); } + @Put('users/:externalUserId') + upsertUser( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalUserId') externalUserId: string, + @Body() body: unknown, + ) { + const parsed = whmcsUpsertClientRequestSchema.parse(body); + return this.whmcs.upsertClient( + request.whmcsInstallation!, + externalUserId, + parsed, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + @Post('services') provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) { const parsed = whmcsProvisionServiceRequestSchema.parse(body); @@ -181,6 +196,21 @@ export class WhmcsIntegrationController { ); } + @Post('services/:externalServiceId/renew') + renewService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + @Body() body: unknown, + ) { + const parsed = (body ?? {}) as { invoiceId?: string; monthlyCreditGrant?: number }; + return this.whmcs.renewService( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + parsed, + ); + } + @Post('services/:externalServiceId/sso') createServiceSso( @Req() request: WhmcsAuthenticatedRequest, @@ -284,6 +314,54 @@ export class WhmcsIntegrationController { headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, ); } + + @Post('services/:externalServiceId/actions/start') + startService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.startServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + + @Post('services/:externalServiceId/actions/stop') + stopService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.stopServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + + @Post('services/:externalServiceId/actions/restart') + restartService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.restartServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + + @Post('services/:externalServiceId/actions/backup') + backupService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.backupServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } } function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined { diff --git a/apps/api/src/integrations/whmcs/whmcs-integration.module.ts b/apps/api/src/integrations/whmcs/whmcs-integration.module.ts index 9b50fef..e0a9059 100644 --- a/apps/api/src/integrations/whmcs/whmcs-integration.module.ts +++ b/apps/api/src/integrations/whmcs/whmcs-integration.module.ts @@ -3,6 +3,7 @@ import Redis from 'ioredis'; import { getConfig } from '@hexahost/config'; +import { BackupsModule } from '../../servers/backups/backups.module'; import { BillingModule } from '../../billing/billing.module'; import { AuthModule } from '../../auth/auth.module'; import { ServersModule } from '../../servers/servers.module'; @@ -20,7 +21,7 @@ import { WhmcsUsageService } from './whmcs-usage.service'; export { INTEGRATIONS_REDIS } from './whmcs-integration.constants'; @Module({ - imports: [ServersModule, BillingModule, AuthModule], + imports: [ServersModule, BackupsModule, BillingModule, AuthModule], controllers: [WhmcsIntegrationController], providers: [ WhmcsIntegrationService, diff --git a/apps/api/src/integrations/whmcs/whmcs-integration.service.ts b/apps/api/src/integrations/whmcs/whmcs-integration.service.ts index eaed280..00561a0 100644 --- a/apps/api/src/integrations/whmcs/whmcs-integration.service.ts +++ b/apps/api/src/integrations/whmcs/whmcs-integration.service.ts @@ -20,6 +20,7 @@ import type { GameServer } from '@hexahost/database'; import { BillingService } from '../../billing/billing.service'; import { getBillingProvider } from '../../billing/billing-provider'; import { PrismaService } from '../../prisma/prisma.service'; +import { BackupsService } from '../../servers/backups/backups.service'; import { ServersService } from '../../servers/servers.service'; import type { WhmcsInstallationContext } from './whmcs-integration.guard'; @@ -34,6 +35,7 @@ export class WhmcsIntegrationService { constructor( private readonly prisma: PrismaService, private readonly serversService: ServersService, + private readonly backupsService: BackupsService, private readonly billing: BillingService, ) {} @@ -356,6 +358,67 @@ export class WhmcsIntegrationService { return response; } + async renewService( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + body: { invoiceId?: string; monthlyCreditGrant?: number }, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const server = link.server; + + if (body.monthlyCreditGrant && body.monthlyCreditGrant > 0) { + const wallet = await this.prisma.creditWallet.findUnique({ + where: { userId: server.userId }, + }); + + if (wallet) { + await this.prisma.$transaction([ + this.prisma.creditWallet.update({ + where: { id: wallet.id }, + data: { balance: { increment: body.monthlyCreditGrant } }, + }), + this.prisma.creditTransaction.create({ + data: { + walletId: wallet.id, + amount: body.monthlyCreditGrant, + type: 'GRANT', + referenceType: 'whmcs_renew', + referenceId: body.invoiceId ?? externalServiceId, + idempotencyKey, + }, + }), + ]); + } + } + + const updatedLink = await this.prisma.whmcsServiceLink.update({ + where: { id: link.id }, + data: { status: 'ACTIVE', suspendedAt: null }, + }); + + const response = this.toServiceResponse( + externalServiceId, + updatedLink.status, + server, + ); + + await this.recordOperation( + installation.id, + idempotencyKey, + 'service:renew', + response, + externalServiceId, + ); + + return response; + } + async terminateService( installation: InstallationRef, externalServiceId: string, @@ -530,4 +593,93 @@ export class WhmcsIntegrationService { }, }; } + + async startServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const result = await this.serversService.startServerInternal( + link.server.id, + `whmcs:start:${externalServiceId}`, + ); + + const response = this.toServiceResponse( + externalServiceId, + link.status, + result.server as unknown as GameServer, + ); + await this.recordOperation(installation.id, idempotencyKey, 'service:start', response, externalServiceId); + return response; + } + + async stopServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const result = await this.serversService.stopServer(link.server.userId, link.server.id); + const response = this.toServiceResponse( + externalServiceId, + link.status, + result.server as unknown as GameServer, + ); + await this.recordOperation(installation.id, idempotencyKey, 'service:stop', response, externalServiceId); + return response; + } + + async restartServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const result = await this.serversService.restartServer(link.server.userId, link.server.id); + const response = this.toServiceResponse( + externalServiceId, + link.status, + result.server as unknown as GameServer, + ); + await this.recordOperation(installation.id, idempotencyKey, 'service:restart', response, externalServiceId); + return response; + } + + async backupServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise<{ externalServiceId: string; jobId: string }> { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as { externalServiceId: string; jobId: string }; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const backup = await this.backupsService.createBackup(link.server.userId, link.server.id, { + label: 'WHMCS backup', + }); + const response = { + externalServiceId, + jobId: backup.jobId ?? idempotencyKey, + }; + await this.recordOperation(installation.id, idempotencyKey, 'service:backup', response, externalServiceId); + return response; + } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b198a6f..ba048dc 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -11,12 +11,14 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { Logger } from 'nestjs-pino'; import { validateConfig } from '@hexahost/config'; +import { initOpenTelemetry, shutdownOpenTelemetry } from '@hexahost/otel'; import { AppModule } from './app.module'; import { Rfc7807ExceptionFilter } from './common/filters/rfc7807-exception.filter'; async function bootstrap(): Promise { const config = validateConfig(); + initOpenTelemetry('hexahost-api'); const app = await NestFactory.create( AppModule, diff --git a/apps/api/src/nodes/nodes.service.ts b/apps/api/src/nodes/nodes.service.ts index 028600f..95d296d 100644 --- a/apps/api/src/nodes/nodes.service.ts +++ b/apps/api/src/nodes/nodes.service.ts @@ -167,6 +167,9 @@ export class NodesService { case 'server.files.list.result': case 'server.files.read.result': case 'server.files.write.result': + case 'server.files.delete.result': + case 'server.files.archive.result': + case 'server.files.unarchive.result': case 'server.command.result': await this.handleDirectResult(envelope); break; diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..e1d38bc --- /dev/null +++ b/apps/api/src/notifications/notifications.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { updateNotificationPreferencesSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { NotificationsService } from './notifications.service'; + +@ApiTags('account') +@Controller('account/notifications') +@UseGuards(SessionGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + list(@CurrentUser() user: { id: string }) { + return this.notificationsService.list(user.id); + } + + @Post(':id/read') + markRead( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.notificationsService.markRead(user.id, id); + } + + @Post('read-all') + markAllRead(@CurrentUser() user: { id: string }) { + return this.notificationsService.markAllRead(user.id); + } + + @Get('preferences') + getPreferences(@CurrentUser() user: { id: string }) { + return this.notificationsService.getPreferences(user.id); + } + + @Patch('preferences') + @ApiOperation({ summary: 'Update notification channel preferences' }) + updatePreferences( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(updateNotificationPreferencesSchema)) body: unknown, + ) { + return this.notificationsService.updatePreferences( + user.id, + body as Parameters[1], + ); + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts new file mode 100644 index 0000000..de25abc --- /dev/null +++ b/apps/api/src/notifications/notifications.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts new file mode 100644 index 0000000..f9bb0cd --- /dev/null +++ b/apps/api/src/notifications/notifications.service.ts @@ -0,0 +1,166 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Queue } from 'bullmq'; + +import type { + NotificationListResponse, + UpdateNotificationPreferences, +} from '@hexahost/contracts'; + +import { NOTIFICATIONS_QUEUE } from '../auth/auth.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class NotificationsService { + constructor( + private readonly prisma: PrismaService, + @Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue, + ) {} + + async list(userId: string): Promise { + const [notifications, unreadCount] = await Promise.all([ + this.prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 100, + }), + this.prisma.notification.count({ + where: { userId, readAt: null }, + }), + ]); + + return { + unreadCount, + notifications: notifications.map((n) => ({ + id: n.id, + type: n.type, + channel: n.channel, + title: n.title, + body: n.body, + readAt: n.readAt?.toISOString() ?? null, + createdAt: n.createdAt.toISOString(), + })), + }; + } + + async markRead(userId: string, notificationId: string) { + await this.prisma.notification.updateMany({ + where: { id: notificationId, userId }, + data: { readAt: new Date() }, + }); + return { read: true }; + } + + async markAllRead(userId: string) { + await this.prisma.notification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: new Date() }, + }); + return { read: true }; + } + + async getPreferences(userId: string): Promise<{ + preferences: Array<{ type: string; channel: string; enabled: boolean }>; + }> { + const prefs = await this.prisma.notificationPreference.findMany({ + where: { userId }, + }); + return { + preferences: prefs.map((p) => ({ + type: p.type, + channel: p.channel, + enabled: p.enabled, + })), + }; + } + + async updatePreferences( + userId: string, + input: UpdateNotificationPreferences, + ): Promise<{ preferences: Array<{ type: string; channel: string; enabled: boolean }> }> { + await this.prisma.$transaction( + input.preferences.map((pref) => + this.prisma.notificationPreference.upsert({ + where: { + userId_type_channel: { + userId, + type: pref.type, + channel: pref.channel, + }, + }, + create: { + userId, + type: pref.type, + channel: pref.channel, + enabled: pref.enabled, + }, + update: { enabled: pref.enabled }, + }), + ), + ); + return this.getPreferences(userId); + } + + async createInApp( + userId: string, + input: { type: NotificationListResponse['notifications'][number]['type']; title: string; body: string }, + ): Promise<{ id: string }> { + const created = await this.prisma.notification.create({ + data: { + userId, + type: input.type, + channel: 'IN_APP', + title: input.title, + body: input.body, + }, + }); + return { id: created.id }; + } + + async dispatch( + userId: string, + input: { + type: NotificationListResponse['notifications'][number]['type']; + title: string; + body: string; + metadata?: Record; + }, + ): Promise { + await this.createInApp(userId, input); + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + if (!user) { + return; + } + + const prefs = await this.prisma.notificationPreference.findMany({ + where: { userId, type: input.type, enabled: true }, + }); + const enabledChannels = new Set(prefs.map((p) => p.channel)); + + const channels = ['EMAIL', 'WEBHOOK', 'DISCORD'] as const; + for (const channel of channels) { + if (!enabledChannels.has(channel) && prefs.length > 0) { + continue; + } + + await this.notificationsQueue.add( + 'dispatch-notification', + { + userId, + type: input.type, + channel, + title: input.title, + body: input.body, + metadata: { + ...input.metadata, + email: user.email, + }, + }, + { removeOnComplete: true }, + ); + } + } +} diff --git a/apps/api/src/organizations/organizations.controller.ts b/apps/api/src/organizations/organizations.controller.ts new file mode 100644 index 0000000..448df62 --- /dev/null +++ b/apps/api/src/organizations/organizations.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + createOrganizationSchema, + inviteOrganizationMemberSchema, +} from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { OrganizationsService } from './organizations.service'; + +@ApiTags('organizations') +@Controller('organizations') +@UseGuards(SessionGuard) +export class OrganizationsController { + constructor(private readonly organizationsService: OrganizationsService) {} + + @Get() + @ApiOperation({ summary: "List current user's organizations" }) + list(@CurrentUser() user: { id: string }) { + return this.organizationsService.list(user.id); + } + + @Post() + @ApiOperation({ summary: 'Create a new organization' }) + create( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(createOrganizationSchema)) body: unknown, + ) { + return this.organizationsService.create( + user.id, + body as Parameters[1], + ); + } + + @Get(':organizationId/members') + @ApiOperation({ summary: 'List organization members' }) + listMembers( + @CurrentUser() user: { id: string }, + @Param('organizationId', ParseUUIDPipe) organizationId: string, + ) { + return this.organizationsService.listMembers(user.id, organizationId); + } + + @Post(':organizationId/members') + @ApiOperation({ summary: 'Invite a member to the organization' }) + inviteMember( + @CurrentUser() user: { id: string }, + @Param('organizationId', ParseUUIDPipe) organizationId: string, + @Body(new ZodValidationPipe(inviteOrganizationMemberSchema)) body: unknown, + ) { + return this.organizationsService.inviteMember( + user.id, + organizationId, + body as Parameters[2], + ); + } +} diff --git a/apps/api/src/organizations/organizations.module.ts b/apps/api/src/organizations/organizations.module.ts new file mode 100644 index 0000000..ab1660b --- /dev/null +++ b/apps/api/src/organizations/organizations.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { OrganizationsController } from './organizations.controller'; +import { OrganizationsService } from './organizations.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [OrganizationsController], + providers: [OrganizationsService], + exports: [OrganizationsService], +}) +export class OrganizationsModule {} diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts new file mode 100644 index 0000000..219619e --- /dev/null +++ b/apps/api/src/organizations/organizations.service.ts @@ -0,0 +1,217 @@ +import { randomUUID } from 'node:crypto'; + +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { generateSecureToken, hashToken } from '@hexahost/auth'; +import type { + CreateOrganization, + InviteOrganizationMember, + Organization, + OrganizationInviteResponse, + OrganizationListResponse, + OrganizationMemberListResponse, +} from '@hexahost/contracts'; +import type { + Organization as OrganizationRow, + OrganizationMember, + OrganizationMemberRole, +} from '@hexahost/database'; + +import { PrismaService } from '../prisma/prisma.service'; + +const INVITE_TTL_DAYS = 7; + +function slugifyName(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); +} + +function mapOrganization( + org: OrganizationRow, + role: OrganizationMemberRole, +): Organization { + return { + id: org.id, + name: org.name, + slug: org.slug, + role, + createdAt: org.createdAt.toISOString(), + }; +} + +@Injectable() +export class OrganizationsService { + constructor(private readonly prisma: PrismaService) {} + + async list(userId: string): Promise { + const memberships = await this.prisma.organizationMember.findMany({ + where: { userId }, + include: { organization: true }, + orderBy: { createdAt: 'asc' }, + }); + + return { + organizations: memberships.map((membership) => + mapOrganization(membership.organization, membership.role), + ), + }; + } + + async create(userId: string, input: CreateOrganization): Promise { + const baseSlug = input.slug ?? slugifyName(input.name); + if (!baseSlug) { + throw new ConflictException('Unable to derive organization slug'); + } + + let slug = baseSlug; + for (let attempt = 0; attempt < 5; attempt += 1) { + const existing = await this.prisma.organization.findUnique({ + where: { slug }, + }); + if (!existing) { + break; + } + slug = `${baseSlug}-${randomUUID().slice(0, 6)}`; + } + + const organization = await this.prisma.$transaction(async (tx) => { + const created = await tx.organization.create({ + data: { + name: input.name.trim(), + slug, + }, + }); + + await tx.organizationMember.create({ + data: { + organizationId: created.id, + userId, + role: 'OWNER', + }, + }); + + return created; + }); + + return mapOrganization(organization, 'OWNER'); + } + + async listMembers( + userId: string, + organizationId: string, + ): Promise { + await this.requireMember(organizationId, userId); + + const members = await this.prisma.organizationMember.findMany({ + where: { organizationId }, + include: { + user: { + select: { + id: true, + email: true, + username: true, + displayName: true, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return { + members: members.map((member) => ({ + id: member.id, + userId: member.userId, + role: member.role, + email: member.user.email, + username: member.user.username, + displayName: member.user.displayName, + createdAt: member.createdAt.toISOString(), + })), + }; + } + + async inviteMember( + userId: string, + organizationId: string, + input: InviteOrganizationMember, + ): Promise { + const membership = await this.requireMember(organizationId, userId); + if (!['OWNER', 'ADMIN'].includes(membership.role)) { + throw new ForbiddenException('Only organization admins can invite members'); + } + + const email = input.email.toLowerCase(); + const existingUser = await this.prisma.user.findUnique({ where: { email } }); + + if (existingUser) { + const member = await this.prisma.organizationMember.upsert({ + where: { + organizationId_userId: { + organizationId, + userId: existingUser.id, + }, + }, + create: { + organizationId, + userId: existingUser.id, + role: input.role, + }, + update: { role: input.role }, + }); + + return { + memberId: member.id, + userId: member.userId, + role: member.role, + }; + } + + const token = generateSecureToken(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + INVITE_TTL_DAYS); + + const invite = await this.prisma.organizationInvite.create({ + data: { + organizationId, + email, + role: input.role, + tokenHash: hashToken(token), + invitedById: userId, + expiresAt, + }, + }); + + return { + id: invite.id, + email: invite.email, + role: invite.role, + expiresAt: invite.expiresAt.toISOString(), + }; + } + + private async requireMember( + organizationId: string, + userId: string, + ): Promise { + const membership = await this.prisma.organizationMember.findUnique({ + where: { + organizationId_userId: { organizationId, userId }, + }, + }); + + if (!membership) { + throw new NotFoundException('Organization not found'); + } + + return membership; + } +} diff --git a/apps/api/src/servers/files/files.controller.ts b/apps/api/src/servers/files/files.controller.ts index be73e7e..0417e88 100644 --- a/apps/api/src/servers/files/files.controller.ts +++ b/apps/api/src/servers/files/files.controller.ts @@ -1,9 +1,13 @@ import { Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, + Post, Put, Query, UseGuards, @@ -11,9 +15,13 @@ import { import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { + archiveFilesSchema, + filePathSchema, listFilesQuerySchema, readFileContentQuerySchema, + unarchiveFileSchema, updateFileContentSchema, + uploadFileSchema, } from '@hexahost/contracts'; import { CurrentUser } from '../../auth/decorators/current-user.decorator'; @@ -30,7 +38,6 @@ export class FilesController { @Get() @ApiOperation({ summary: 'List files in the server data directory' }) - @ApiResponse({ status: 200, description: 'Directory listing' }) list( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, @@ -41,7 +48,6 @@ export class FilesController { @Get('content') @ApiOperation({ summary: 'Read a text file from the server' }) - @ApiResponse({ status: 200, description: 'File content' }) read( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, @@ -53,7 +59,6 @@ export class FilesController { @Put('content') @ApiOperation({ summary: 'Write a text file on the server' }) - @ApiResponse({ status: 200, description: 'Updated file content' }) write( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, @@ -65,4 +70,59 @@ export class FilesController { body as Parameters[2], ); } + + @Post('upload') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Upload a file (base64) to the server' }) + upload( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(uploadFileSchema)) body: unknown, + ) { + return this.filesService.uploadFile( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete() + @ApiOperation({ summary: 'Delete a file or directory' }) + delete( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Query(new ZodValidationPipe(filePathSchema)) query: { path: string }, + ) { + return this.filesService.deleteFile(user.id, serverId, query.path); + } + + @Post('archive') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a tar.gz archive from selected paths' }) + archive( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(archiveFilesSchema)) body: unknown, + ) { + return this.filesService.archiveFiles( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Post('unarchive') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Extract a tar.gz archive on the server' }) + unarchive( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(unarchiveFileSchema)) body: unknown, + ) { + return this.filesService.unarchiveFile( + user.id, + serverId, + body as Parameters[2], + ); + } } diff --git a/apps/api/src/servers/files/files.service.ts b/apps/api/src/servers/files/files.service.ts index 24532c2..e3a874c 100644 --- a/apps/api/src/servers/files/files.service.ts +++ b/apps/api/src/servers/files/files.service.ts @@ -5,9 +5,12 @@ import { } from '@nestjs/common'; import type { + ArchiveFiles, FileContent, FileListResponse, + UnarchiveFile, UpdateFileContent, + UploadFile, } from '@hexahost/contracts'; import type { GameServer } from '@hexahost/database'; @@ -19,6 +22,15 @@ import { assertSafeServerPath } from '../shared/server-path.util'; const FILE_ACCESS_STATUSES = new Set(['RUNNING', 'STOPPED']); +interface AgentFileEntry { + name: string; + path: string; + isDir?: boolean; + type?: 'file' | 'directory'; + size?: number; + modifiedAt?: string; +} + @Injectable() export class FilesService { constructor( @@ -51,7 +63,18 @@ export class FilesService { ); } - return response.result as FileListResponse; + const raw = response.result as { entries?: AgentFileEntry[] }; + const entries = (raw.entries ?? []).map((entry) => ({ + name: entry.name, + path: entry.path, + type: (entry.isDir ?? entry.type === 'directory' ? 'directory' : 'file') as + | 'file' + | 'directory', + size: entry.size, + modifiedAt: entry.modifiedAt, + })); + + return { path: safePath, entries }; } async readFile( @@ -78,7 +101,12 @@ export class FilesService { ); } - return response.result as FileContent; + const raw = response.result as { path: string; content: string }; + return { + path: raw.path, + content: raw.content, + encoding: 'utf-8', + }; } async writeFile( @@ -98,6 +126,7 @@ export class FilesService { path: safePath, content: input.content, encoding: input.encoding, + create: true, }, ); @@ -115,7 +144,140 @@ export class FilesService { metadata: { path: safePath }, }); - return response.result as FileContent; + return { + path: safePath, + content: input.content, + encoding: input.encoding ?? 'utf-8', + }; + } + + async uploadFile( + userId: string, + serverId: string, + input: UploadFile, + ): Promise { + const content = + input.encoding === 'base64' + ? Buffer.from(input.content, 'base64').toString('utf-8') + : input.content; + + return this.writeFile(userId, serverId, { + path: input.path, + content, + encoding: 'utf-8', + }); + } + + async deleteFile(userId: string, serverId: string, path: string): Promise<{ path: string }> { + const server = await this.requireFileAccess(userId, serverId); + const safePath = assertSafeServerPath(path); + + const response = await this.nodeBridge.sendAgentRequest( + server.nodeId!, + 'server.files.delete', + { + serverId, + generation: server.version, + path: safePath, + }, + ); + + if (!response.success) { + throw new ServiceUnavailableException( + response.errorMessage ?? 'Failed to delete file', + ); + } + + await this.auditService.record({ + action: 'server.file.delete', + userId, + entityType: 'game_server', + entityId: serverId, + metadata: { path: safePath }, + }); + + return { path: safePath }; + } + + async archiveFiles( + userId: string, + serverId: string, + input: ArchiveFiles, + ): Promise<{ archivePath: string; sha256?: string; size?: number }> { + const server = await this.requireFileAccess(userId, serverId); + const paths = input.paths.map((p) => assertSafeServerPath(p)); + + const response = await this.nodeBridge.sendAgentRequest( + server.nodeId!, + 'server.files.archive', + { + serverId, + generation: server.version, + paths, + archiveName: input.archiveName, + }, + 120_000, + ); + + if (!response.success) { + throw new ServiceUnavailableException( + response.errorMessage ?? 'Failed to create archive', + ); + } + + const result = response.result as { + archivePath: string; + sha256?: string; + size?: number; + }; + + await this.auditService.record({ + action: 'server.file.archive', + userId, + entityType: 'game_server', + entityId: serverId, + metadata: { paths, archivePath: result.archivePath }, + }); + + return result; + } + + async unarchiveFile( + userId: string, + serverId: string, + input: UnarchiveFile, + ): Promise<{ destinationPath: string }> { + const server = await this.requireFileAccess(userId, serverId); + const archivePath = assertSafeServerPath(input.archivePath); + const destinationPath = assertSafeServerPath(input.destinationPath); + + const response = await this.nodeBridge.sendAgentRequest( + server.nodeId!, + 'server.files.unarchive', + { + serverId, + generation: server.version, + archivePath, + destinationPath, + }, + 120_000, + ); + + if (!response.success) { + throw new ServiceUnavailableException( + response.errorMessage ?? 'Failed to extract archive', + ); + } + + await this.auditService.record({ + action: 'server.file.unarchive', + userId, + entityType: 'game_server', + entityId: serverId, + metadata: { archivePath, destinationPath }, + }); + + return { destinationPath }; } private async requireFileAccess( diff --git a/apps/api/src/servers/members/members.controller.ts b/apps/api/src/servers/members/members.controller.ts new file mode 100644 index 0000000..e0b37ab --- /dev/null +++ b/apps/api/src/servers/members/members.controller.ts @@ -0,0 +1,119 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { z } from 'zod'; + +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { PrismaService } from '../../prisma/prisma.service'; +import { findAccessibleServer } from '../shared/server-ownership.util'; + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.enum(['ADMIN', 'OPERATOR', 'DEVELOPER', 'VIEWER']).default('VIEWER'), +}); + +@ApiTags('servers') +@Controller('servers/:serverId/members') +@UseGuards(SessionGuard) +export class MembersController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + @ApiOperation({ summary: 'List server members' }) + async list( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + ): Promise<{ + members: Array<{ + id: string; + userId: string; + role: string; + email: string; + username: string; + displayName: string | null; + acceptedAt: string | null; + }>; + }> { + await findAccessibleServer(this.prisma, user.id, serverId, 'server.view'); + + const members = await this.prisma.gameServerMember.findMany({ + where: { serverId }, + include: { + user: { select: { id: true, email: true, username: true, displayName: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return { + members: members.map((member) => ({ + id: member.id, + userId: member.userId, + role: member.role, + email: member.user.email, + username: member.user.username, + displayName: member.user.displayName, + acceptedAt: member.acceptedAt?.toISOString() ?? null, + })), + }; + } + + @Post() + @ApiOperation({ summary: 'Invite a user to the server' }) + async invite( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(inviteSchema)) body: unknown, + ): Promise<{ status?: string; message?: string; id?: string; userId?: string; role?: string }> { + await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage'); + const input = body as z.infer; + + const invitedUser = await this.prisma.user.findUnique({ + where: { email: input.email.toLowerCase() }, + }); + + if (!invitedUser) { + return { status: 'pending', message: 'User must register before accepting invite' }; + } + + const member = await this.prisma.gameServerMember.upsert({ + where: { + serverId_userId: { serverId, userId: invitedUser.id }, + }, + create: { + serverId, + userId: invitedUser.id, + role: input.role, + acceptedAt: new Date(), + }, + update: { role: input.role }, + }); + + return { id: member.id, userId: member.userId, role: member.role }; + } + + @Delete(':memberId') + @ApiOperation({ summary: 'Remove a server member' }) + async remove( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Param('memberId', ParseUUIDPipe) memberId: string, + ) { + await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage'); + + await this.prisma.gameServerMember.deleteMany({ + where: { id: memberId, serverId }, + }); + + return { removed: true }; + } +} diff --git a/apps/api/src/servers/members/members.module.ts b/apps/api/src/servers/members/members.module.ts new file mode 100644 index 0000000..8f7047f --- /dev/null +++ b/apps/api/src/servers/members/members.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +import { MembersController } from './members.controller'; + +@Module({ + controllers: [MembersController], +}) +export class MembersModule {} diff --git a/apps/api/src/servers/players/players.controller.ts b/apps/api/src/servers/players/players.controller.ts index a5a9613..1b9537e 100644 --- a/apps/api/src/servers/players/players.controller.ts +++ b/apps/api/src/servers/players/players.controller.ts @@ -1,14 +1,27 @@ import { + Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, + Post, UseGuards, } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + addBanSchema, + addOperatorSchema, + addWhitelistSchema, + removePlayerEntrySchema, +} from '@hexahost/contracts'; import { CurrentUser } from '../../auth/decorators/current-user.decorator'; import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; import { PlayersService } from './players.service'; @@ -19,12 +32,105 @@ export class PlayersController { constructor(private readonly playersService: PlayersService) {} @Get() - @ApiOperation({ summary: 'List online players, whitelist, and operators' }) - @ApiResponse({ status: 200, description: 'Player overview' }) + @ApiOperation({ summary: 'List online players, whitelist, operators, and bans' }) list( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, ) { return this.playersService.listPlayers(user.id, serverId); } + + @Post('whitelist') + addWhitelist( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addWhitelistSchema)) body: unknown, + ) { + return this.playersService.addWhitelist( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete('whitelist') + removeWhitelist( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown, + ) { + return this.playersService.removeWhitelist( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Post('operators') + addOperator( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addOperatorSchema)) body: unknown, + ) { + return this.playersService.addOperator( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete('operators') + removeOperator( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown, + ) { + return this.playersService.removeOperator( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Post('bans') + addBan( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addBanSchema)) body: unknown, + ) { + return this.playersService.addBan( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete('bans') + removeBan( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addBanSchema.pick({ name: true }))) body: unknown, + ) { + return this.playersService.removeBan( + user.id, + serverId, + body as { name: string }, + ); + } + + @Post('kick') + @HttpCode(HttpStatus.ACCEPTED) + kick( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addBanSchema.pick({ name: true }).extend({ + reason: addBanSchema.shape.reason, + }))) body: unknown, + ) { + return this.playersService.kickPlayer( + user.id, + serverId, + body as { name: string; reason?: string }, + ); + } } diff --git a/apps/api/src/servers/players/players.service.ts b/apps/api/src/servers/players/players.service.ts index ecf64ab..8545d47 100644 --- a/apps/api/src/servers/players/players.service.ts +++ b/apps/api/src/servers/players/players.service.ts @@ -3,7 +3,13 @@ import { Injectable, } from '@nestjs/common'; -import type { PlayerListResponse } from '@hexahost/contracts'; +import type { + AddBan, + AddOperator, + AddWhitelist, + PlayerListResponse, + RemovePlayerEntry, +} from '@hexahost/contracts'; import type { GameServer } from '@hexahost/database'; import { NodeBridgeService } from '../../nodes/node-bridge.service'; @@ -13,6 +19,7 @@ import { findOwnedServer } from '../shared/server-ownership.util'; const WHITELIST_FILE = 'whitelist.json'; const OPS_FILE = 'ops.json'; +const BANS_FILE = 'banned-players.json'; @Injectable() export class PlayersService { @@ -32,17 +39,114 @@ export class PlayersService { throw new BadRequestException('Server is not assigned to a node'); } - const [online, whitelist, operators] = await Promise.all([ + const [online, whitelist, operators, bans] = await Promise.all([ this.fetchOnlinePlayers(server), this.readJsonFile(userId, serverId, WHITELIST_FILE, []), this.readJsonFile(userId, serverId, OPS_FILE, []), + this.readJsonFile(userId, serverId, BANS_FILE, []), ]); - return { - online, - whitelist, - operators, - }; + return { online, whitelist, operators, bans }; + } + + async addWhitelist(userId: string, serverId: string, input: AddWhitelist) { + const list = await this.readJsonFile>( + userId, + serverId, + WHITELIST_FILE, + [], + ); + const next = list.filter((e) => e.uuid !== input.uuid); + next.push({ uuid: input.uuid, name: input.name }); + await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next); + await this.runCommandIfRunning(userId, serverId, `whitelist add ${input.name}`); + return { whitelist: next }; + } + + async removeWhitelist(userId: string, serverId: string, input: RemovePlayerEntry) { + const list = await this.readJsonFile>( + userId, + serverId, + WHITELIST_FILE, + [], + ); + const entry = list.find((e) => e.uuid === input.uuid); + const next = list.filter((e) => e.uuid !== input.uuid); + await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next); + if (entry) { + await this.runCommandIfRunning(userId, serverId, `whitelist remove ${entry.name}`); + } + return { whitelist: next }; + } + + async addOperator(userId: string, serverId: string, input: AddOperator) { + const list = await this.readJsonFile< + Array<{ uuid: string; name: string; level?: number; bypassesPlayerLimit?: boolean }> + >(userId, serverId, OPS_FILE, []); + const next = list.filter((e) => e.uuid !== input.uuid); + next.push({ + uuid: input.uuid, + name: input.name, + level: input.level, + bypassesPlayerLimit: input.bypassesPlayerLimit, + }); + await this.writeJsonFile(userId, serverId, OPS_FILE, next); + await this.runCommandIfRunning( + userId, + serverId, + `op ${input.name}`, + ); + return { operators: next }; + } + + async removeOperator(userId: string, serverId: string, input: RemovePlayerEntry) { + const list = await this.readJsonFile>( + userId, + serverId, + OPS_FILE, + [], + ); + const entry = list.find((e) => e.uuid === input.uuid); + const next = list.filter((e) => e.uuid !== input.uuid); + await this.writeJsonFile(userId, serverId, OPS_FILE, next); + if (entry) { + await this.runCommandIfRunning(userId, serverId, `deop ${entry.name}`); + } + return { operators: next }; + } + + async addBan(userId: string, serverId: string, input: AddBan) { + const list = await this.readJsonFile< + Array<{ uuid?: string; name: string; reason?: string; source?: string }> + >(userId, serverId, BANS_FILE, []); + const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase()); + next.push({ + name: input.name, + reason: input.reason ?? 'Banned by panel', + source: 'HexaHost Panel', + }); + await this.writeJsonFile(userId, serverId, BANS_FILE, next); + await this.runCommandIfRunning(userId, serverId, `ban ${input.name}`); + return { bans: next }; + } + + async removeBan(userId: string, serverId: string, input: { name: string }) { + const list = await this.readJsonFile>( + userId, + serverId, + BANS_FILE, + [], + ); + const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase()); + await this.writeJsonFile(userId, serverId, BANS_FILE, next); + await this.runCommandIfRunning(userId, serverId, `pardon ${input.name}`); + return { bans: next }; + } + + async kickPlayer(userId: string, serverId: string, input: { name: string; reason?: string }) { + const reason = input.reason ? ` ${input.reason}` : ''; + await this.runCommandIfRunning(userId, serverId, `kick ${input.name}${reason}`); + return { kicked: input.name }; } private async fetchOnlinePlayers( @@ -106,4 +210,39 @@ export class PlayersService { return fallback; } } + + private async writeJsonFile( + userId: string, + serverId: string, + path: string, + data: T, + ): Promise { + await this.filesService.writeFile(userId, serverId, { + path, + content: `${JSON.stringify(data, null, 2)}\n`, + encoding: 'utf-8', + }); + } + + private async runCommandIfRunning( + userId: string, + serverId: string, + command: string, + ): Promise { + const server = await findOwnedServer(this.prisma, userId, serverId); + if (server.status !== 'RUNNING' || !server.nodeId) { + return; + } + + await this.nodeBridge.sendAgentRequest( + server.nodeId, + 'server.command', + { + serverId, + generation: server.version, + command, + }, + 10_000, + ); + } } diff --git a/apps/api/src/servers/schedules/schedules.controller.ts b/apps/api/src/servers/schedules/schedules.controller.ts new file mode 100644 index 0000000..3da1c1e --- /dev/null +++ b/apps/api/src/servers/schedules/schedules.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { createScheduleSchema, updateScheduleSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; + +import { SchedulesService } from './schedules.service'; + +@ApiTags('servers') +@Controller('servers/:serverId/schedules') +@UseGuards(SessionGuard) +export class SchedulesController { + constructor(private readonly schedulesService: SchedulesService) {} + + @Get() + @ApiOperation({ summary: 'List server schedules' }) + list( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + ) { + return this.schedulesService.list(user.id, serverId); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + create( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(createScheduleSchema)) body: unknown, + ) { + return this.schedulesService.create( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Patch(':scheduleId') + update( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body(new ZodValidationPipe(updateScheduleSchema)) body: unknown, + ) { + return this.schedulesService.update( + user.id, + serverId, + scheduleId, + body as Parameters[3], + ); + } + + @Delete(':scheduleId') + @HttpCode(HttpStatus.NO_CONTENT) + async remove( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + ) { + await this.schedulesService.remove(user.id, serverId, scheduleId); + } +} diff --git a/apps/api/src/servers/schedules/schedules.module.ts b/apps/api/src/servers/schedules/schedules.module.ts new file mode 100644 index 0000000..4ba589b --- /dev/null +++ b/apps/api/src/servers/schedules/schedules.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../../auth/auth.module'; +import { PrismaModule } from '../../prisma/prisma.module'; + +import { SchedulesController } from './schedules.controller'; +import { SchedulesService } from './schedules.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [SchedulesController], + providers: [SchedulesService], + exports: [SchedulesService], +}) +export class SchedulesModule {} diff --git a/apps/api/src/servers/schedules/schedules.service.ts b/apps/api/src/servers/schedules/schedules.service.ts new file mode 100644 index 0000000..20faf34 --- /dev/null +++ b/apps/api/src/servers/schedules/schedules.service.ts @@ -0,0 +1,150 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CronExpressionParser } from 'cron-parser'; + +import type { + CreateSchedule, + Schedule as ScheduleDto, + ScheduleListResponse, + UpdateSchedule, +} from '@hexahost/contracts'; +import type { Prisma, Schedule, ScheduledTask } from '@hexahost/database'; + +import { PrismaService } from '../../prisma/prisma.service'; +import { findOwnedServer } from '../shared/server-ownership.util'; + +function mapSchedule( + schedule: Schedule & { tasks: ScheduledTask[] }, +): ScheduleDto { + return { + id: schedule.id, + name: schedule.name, + timezone: schedule.timezone, + cronExpr: schedule.cronExpr, + enabled: schedule.enabled, + nextRunAt: schedule.nextRunAt?.toISOString() ?? null, + lastRunAt: schedule.lastRunAt?.toISOString() ?? null, + tasks: schedule.tasks.map((task) => ({ + id: task.id, + taskType: task.taskType, + payload: (task.payload as Record | null) ?? null, + })), + }; +} + +@Injectable() +export class SchedulesService { + constructor(private readonly prisma: PrismaService) {} + + async list(userId: string, serverId: string): Promise { + await findOwnedServer(this.prisma, userId, serverId); + const schedules = await this.prisma.schedule.findMany({ + where: { serverId }, + include: { tasks: true }, + orderBy: { createdAt: 'asc' }, + }); + return { schedules: schedules.map(mapSchedule) }; + } + + async create(userId: string, serverId: string, input: CreateSchedule) { + await findOwnedServer(this.prisma, userId, serverId); + const schedule = await this.prisma.schedule.create({ + data: { + serverId, + name: input.name, + timezone: input.timezone, + cronExpr: input.cronExpr, + enabled: input.enabled, + nextRunAt: this.computeNextRun(input.cronExpr, input.timezone), + tasks: { + create: input.tasks.map((task) => ({ + taskType: task.taskType, + payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined, + })), + }, + }, + include: { tasks: true }, + }); + return mapSchedule(schedule); + } + + async update( + userId: string, + serverId: string, + scheduleId: string, + input: UpdateSchedule, + ) { + await this.requireSchedule(userId, serverId, scheduleId); + const existing = await this.prisma.schedule.findUniqueOrThrow({ + where: { id: scheduleId }, + }); + + await this.prisma.$transaction(async (tx) => { + if (input.tasks) { + await tx.scheduledTask.deleteMany({ where: { scheduleId } }); + await tx.scheduledTask.createMany({ + data: input.tasks.map((task) => ({ + scheduleId, + taskType: task.taskType, + payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined, + })), + }); + } + + await tx.schedule.update({ + where: { id: scheduleId }, + data: { + name: input.name, + timezone: input.timezone, + cronExpr: input.cronExpr, + enabled: input.enabled, + nextRunAt: + input.cronExpr !== undefined || input.timezone !== undefined + ? this.computeNextRun( + input.cronExpr ?? existing.cronExpr, + input.timezone ?? existing.timezone, + ) + : undefined, + }, + }); + }); + + const schedule = await this.prisma.schedule.findUniqueOrThrow({ + where: { id: scheduleId }, + include: { tasks: true }, + }); + return mapSchedule(schedule); + } + + async remove(userId: string, serverId: string, scheduleId: string) { + await this.requireSchedule(userId, serverId, scheduleId); + await this.prisma.schedule.delete({ where: { id: scheduleId } }); + return { deleted: true }; + } + + private async requireSchedule(userId: string, serverId: string, scheduleId: string) { + await findOwnedServer(this.prisma, userId, serverId); + const schedule = await this.prisma.schedule.findFirst({ + where: { id: scheduleId, serverId }, + }); + if (!schedule) { + throw new NotFoundException('Schedule not found'); + } + return schedule; + } + + private computeNextRun(cronExpr: string, timezone = 'UTC'): Date { + try { + const interval = CronExpressionParser.parse(cronExpr, { + tz: timezone, + currentDate: new Date(), + }); + return interval.next().toDate(); + } catch { + throw new BadRequestException('Invalid cron expression or timezone'); + } + } +} diff --git a/apps/api/src/servers/server-state.service.ts b/apps/api/src/servers/server-state.service.ts index 0c6726e..f3e111f 100644 --- a/apps/api/src/servers/server-state.service.ts +++ b/apps/api/src/servers/server-state.service.ts @@ -1,34 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import type { GameServer } from '@hexahost/database'; - -type GameServerStatus = GameServer['status']; - -const ALLOWED_TRANSITIONS: Record = { - DRAFT: ['PROVISIONING', 'DELETING'], - PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'], - INSTALLING: ['STOPPED', 'ERROR', 'DELETING'], - STOPPED: ['STARTING', 'QUEUED', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'], - QUEUED: ['STARTING', 'STOPPED', 'DELETING'], - STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], - RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'UNKNOWN', 'DELETING'], - STOPPING: ['STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], - BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'], - RESTORING: ['STOPPED', 'ERROR'], - UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'], - ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'], - DELETING: ['DELETED'], - DELETED: [], -}; - -const IN_PROGRESS_STATUSES: GameServerStatus[] = [ - 'PROVISIONING', - 'INSTALLING', - 'STARTING', - 'STOPPING', - 'BACKING_UP', - 'RESTORING', - 'DELETING', -]; +import { + ALLOWED_TRANSITIONS, + IN_PROGRESS_STATUSES, + assertTransition, + isInProgress, + resolveKillTarget, + resolveStartTarget, + resolveStopTarget, + type GameServerStatus, +} from '@hexahost/server-state'; @Injectable() export class ServerStateService { @@ -36,44 +17,56 @@ export class ServerStateService { fromStatus: GameServerStatus, toStatus: GameServerStatus, ): void { - const allowed = ALLOWED_TRANSITIONS[fromStatus]; - - if (!allowed.includes(toStatus)) { + try { + assertTransition(fromStatus, toStatus); + } catch (error) { throw new BadRequestException( - `Invalid state transition from ${fromStatus} to ${toStatus}`, + error instanceof Error ? error.message : 'Invalid state transition', ); } } assertNotInProgress(status: GameServerStatus): void { - if (IN_PROGRESS_STATUSES.includes(status)) { + if (isInProgress(status)) { throw new BadRequestException('Server operation already in progress'); } } resolveStartTarget(status: GameServerStatus): GameServerStatus { - switch (status) { - case 'DRAFT': - case 'ERROR': - return 'PROVISIONING'; - case 'STOPPED': - case 'UNKNOWN': - case 'QUEUED': - return 'STARTING'; - default: - throw new BadRequestException( - `Cannot start server while in status ${status}`, - ); + try { + return resolveStartTarget(status); + } catch (error) { + throw new BadRequestException( + error instanceof Error ? error.message : 'Cannot start server', + ); } } resolveStopTarget(status: GameServerStatus): GameServerStatus { - if (status !== 'RUNNING') { + try { + return resolveStopTarget(status); + } catch (error) { throw new BadRequestException( - `Cannot stop server while in status ${status}`, + error instanceof Error ? error.message : 'Cannot stop server', ); } + } - return 'STOPPING'; + resolveKillTarget(status: GameServerStatus): GameServerStatus { + try { + return resolveKillTarget(status); + } catch (error) { + throw new BadRequestException( + error instanceof Error ? error.message : 'Cannot kill server', + ); + } + } + + allowedTransitions(): typeof ALLOWED_TRANSITIONS { + return ALLOWED_TRANSITIONS; + } + + inProgressStatuses(): GameServerStatus[] { + return IN_PROGRESS_STATUSES; } } diff --git a/apps/api/src/servers/servers.controller.ts b/apps/api/src/servers/servers.controller.ts index e88992f..29baf98 100644 --- a/apps/api/src/servers/servers.controller.ts +++ b/apps/api/src/servers/servers.controller.ts @@ -1,18 +1,20 @@ import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, + Patch, Post, Query, UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { createServerRequestSchema } from '@hexahost/contracts'; +import { createServerRequestSchema, updateServerRequestSchema } from '@hexahost/contracts'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { SessionGuard } from '../auth/guards/session.guard'; @@ -90,6 +92,40 @@ export class ServersController { return this.serversService.restartServer(user.id, id, skip === 'true'); } + @Post(':id/kill') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Force-kill a running game server' }) + kill( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.serversService.killServer(user.id, id); + } + + @Delete(':id') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Delete a game server' }) + delete( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.serversService.deleteServer(user.id, id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update server metadata' }) + patch( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(updateServerRequestSchema)) body: unknown, + ) { + return this.serversService.updateServer( + user.id, + id, + body as Parameters[2], + ); + } + @Get(':id/start-queue') @ApiOperation({ summary: 'Get start queue status for a server' }) startQueue( diff --git a/apps/api/src/servers/servers.module.ts b/apps/api/src/servers/servers.module.ts index aa04290..b3f8477 100644 --- a/apps/api/src/servers/servers.module.ts +++ b/apps/api/src/servers/servers.module.ts @@ -9,8 +9,12 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module'; import { AppConfigModule } from '../config/app-config.module'; import { BillingModule } from '../billing/billing.module'; +import { FeatureFlagsModule } from '../feature-flags/feature-flags.module'; import { SchedulingModule } from '../scheduling/scheduling.module'; +import { MembersModule } from './members/members.module'; +import { SchedulesModule } from './schedules/schedules.module'; +import { SoftwareModule } from './software/software.module'; import { ConsoleModule } from './console/console.module'; import { AddonsModule } from './addons/addons.module'; import { BackupsModule } from './backups/backups.module'; @@ -34,6 +38,7 @@ import { PlansService } from './plans.service'; imports: [ AppConfigModule, AuthModule, + FeatureFlagsModule, SchedulingModule, BillingModule, IdleModule, @@ -44,6 +49,9 @@ import { PlansService } from './plans.service'; forwardRef(() => FilesModule), forwardRef(() => PropertiesModule), forwardRef(() => PlayersModule), + forwardRef(() => MembersModule), + forwardRef(() => SchedulesModule), + forwardRef(() => SoftwareModule), forwardRef(() => NodeBridgeModule), ], controllers: [ServersController, PlansController], @@ -88,6 +96,7 @@ import { PlansService } from './plans.service'; exports: [ ServersService, REDIS_CLIENT, + SERVER_LIFECYCLE_QUEUE, ConsoleModule, NodeBridgeModule, ], diff --git a/apps/api/src/servers/servers.service.ts b/apps/api/src/servers/servers.service.ts index 8fa79ca..5c0e249 100644 --- a/apps/api/src/servers/servers.service.ts +++ b/apps/api/src/servers/servers.service.ts @@ -5,6 +5,7 @@ import { ForbiddenException, Inject, Injectable, + ServiceUnavailableException, } from '@nestjs/common'; import { Queue } from 'bullmq'; import type Redis from 'ioredis'; @@ -16,10 +17,12 @@ import type { ServerResponse, } from '@hexahost/contracts'; import type { GameServer } from '@hexahost/database'; +import { FeatureFlagService } from '@hexahost/feature-flags'; import { formatJoinAddress } from '@hexahost/dns'; type GameServerStatus = GameServer['status']; +import { FEATURE_FLAG_SERVICE } from '../feature-flags/feature-flags.module'; import { PrismaService } from '../prisma/prisma.service'; import { BillingService } from '../billing/billing.service'; import { SchedulingService } from '../scheduling/scheduling.service'; @@ -45,12 +48,14 @@ export class ServersService { private readonly lifecycleQueue: Queue, @Inject(SERVER_PROVISIONING_QUEUE) private readonly provisioningQueue: Queue, + @Inject(FEATURE_FLAG_SERVICE) private readonly featureFlags: FeatureFlagService, ) {} async createServer( userId: string, input: CreateServerRequest, ): Promise { + await this.assertMaintenanceAllowed('create'); await this.billing.assertCanCreateServer(userId, input.planId); const server = await this.prisma.gameServer.create({ @@ -108,6 +113,7 @@ export class ServersService { userId: string, serverId: string, ): Promise { + await this.assertMaintenanceAllowed('start'); const server = await findOwnedServer(this.prisma, userId, serverId); this.assertServerNotBillingSuspended(server); this.serverState.assertNotInProgress(server.status); @@ -323,6 +329,180 @@ export class ServersService { }; } + async killServer( + userId: string, + serverId: string, + ): Promise { + const server = await findOwnedServer(this.prisma, userId, serverId); + this.assertServerNotBillingSuspended(server); + + const targetStatus = this.serverState.resolveKillTarget(server.status); + this.serverState.assertTransition(server.status, targetStatus); + + const correlationId = randomUUID(); + const updated = await this.transitionServer( + server, + targetStatus, + 'kill requested', + userId, + correlationId, + ); + + const job = await this.lifecycleQueue.add( + 'kill-server', + { serverId, action: 'kill', correlationId }, + { jobId: correlationId }, + ); + + if (updated.nodeId) { + await this.publishNodeCommand(updated.nodeId, { + protocolVersion: 1, + messageId: randomUUID(), + correlationId, + type: 'server.kill', + timestamp: new Date().toISOString(), + payload: { + serverId, + generation: updated.version, + }, + }); + } + + return { + server: this.toResponse(updated), + jobId: job.id, + }; + } + + async deleteServer( + userId: string, + serverId: string, + ): Promise { + const server = await findOwnedServer(this.prisma, userId, serverId); + this.serverState.assertNotInProgress(server.status); + + if (server.status === 'RUNNING' || server.status === 'STARTING') { + throw new ConflictException('Stop the server before deleting it'); + } + + this.serverState.assertTransition(server.status, 'DELETING'); + + const correlationId = randomUUID(); + const updated = await this.transitionServer( + server, + 'DELETING', + 'delete requested', + userId, + correlationId, + ); + + const job = await this.lifecycleQueue.add( + 'delete-server', + { serverId, action: 'delete', correlationId }, + { jobId: correlationId }, + ); + + if (updated.nodeId) { + await this.publishNodeCommand(updated.nodeId, { + protocolVersion: 1, + messageId: randomUUID(), + correlationId, + type: 'server.delete', + timestamp: new Date().toISOString(), + payload: { + serverId, + generation: updated.version, + }, + }); + } + + return { + server: this.toResponse(updated), + jobId: job.id, + }; + } + + async updateServer( + userId: string, + serverId: string, + input: { name?: string; idleShutdownEnabled?: boolean }, + ): Promise { + await findOwnedServer(this.prisma, userId, serverId); + + const updated = await this.prisma.gameServer.update({ + where: { id: serverId }, + data: { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.idleShutdownEnabled !== undefined + ? { idleShutdownEnabled: input.idleShutdownEnabled } + : {}), + }, + }); + + return this.toResponse(updated); + } + + async reinstallServer( + userId: string, + serverId: string, + input: { + softwareFamily: string; + minecraftVersion: string; + createBackup?: boolean; + }, + ): Promise<{ serverId: string; jobId: string; status: string }> { + const server = await findOwnedServer(this.prisma, userId, serverId); + this.serverState.assertNotInProgress(server.status); + + if (input.createBackup) { + await this.lifecycleQueue.add('backup-server', { + serverId, + type: 'MANUAL', + reason: 'pre-reinstall', + }); + } + + const correlationId = randomUUID(); + const updated = await this.prisma.gameServer.update({ + where: { id: serverId }, + data: { + softwareFamily: input.softwareFamily as GameServer['softwareFamily'], + minecraftVersion: input.minecraftVersion, + status: 'PROVISIONING', + version: { increment: 1 }, + }, + }); + + await this.prisma.gameServerStateTransition.create({ + data: { + gameServerId: serverId, + fromStatus: server.status, + toStatus: 'PROVISIONING', + reason: 'Software reinstall requested', + actorId: userId, + correlationId, + }, + }); + + const job = await this.provisioningQueue.add( + 'provision-server', + { + serverId, + correlationId, + reinstall: true, + softwareFamily: input.softwareFamily, + minecraftVersion: input.minecraftVersion, + }, + { jobId: correlationId }, + ); + + return { + serverId, + jobId: job.id ?? correlationId, + status: updated.status, + }; + } + async publishNodeCommand( nodeId: string, envelope: Record, @@ -422,6 +602,15 @@ export class ServersService { } } + private async assertMaintenanceAllowed(action: string): Promise { + const maintenance = await this.featureFlags.isEnabled('maintenance_mode'); + if (maintenance) { + throw new ServiceUnavailableException( + `Platform maintenance mode is active; cannot ${action} servers`, + ); + } + } + private async recordTransition( gameServerId: string, fromStatus: GameServerStatus | null, diff --git a/apps/api/src/servers/shared/server-ownership.util.ts b/apps/api/src/servers/shared/server-ownership.util.ts index cf790a5..4aea3aa 100644 --- a/apps/api/src/servers/shared/server-ownership.util.ts +++ b/apps/api/src/servers/shared/server-ownership.util.ts @@ -3,25 +3,75 @@ import { NotFoundException, } from '@nestjs/common'; import type { GameServer } from '@hexahost/database'; +import { + assertPermission, + type ServerMemberRole, + type ServerPermission, +} from '@hexahost/permissions'; import type { PrismaService } from '../../prisma/prisma.service'; +export interface ServerAccessContext { + server: GameServer; + role: ServerMemberRole; + permissionOverrides: ServerPermission[] | null; +} + export async function findOwnedServer( prisma: PrismaService, userId: string, serverId: string, ): Promise { + const access = await findAccessibleServer(prisma, userId, serverId, 'server.view'); + return access.server; +} + +export async function findAccessibleServer( + prisma: PrismaService, + userId: string, + serverId: string, + permission: ServerPermission, +): Promise { const server = await prisma.gameServer.findUnique({ where: { id: serverId }, + include: { + members: { + where: { userId }, + }, + }, }); if (!server) { throw new NotFoundException('Server not found'); } - if (server.userId !== userId) { - throw new ForbiddenException('You do not own this server'); + let role: ServerMemberRole; + let overrides: ServerPermission[] | null = null; + + if (server.userId === userId) { + role = 'OWNER'; + } else { + const membership = server.members[0]; + + if (!membership) { + throw new ForbiddenException('You do not have access to this server'); + } + + role = membership.role as ServerMemberRole; + overrides = Array.isArray(membership.permissions) + ? (membership.permissions as ServerPermission[]) + : null; } - return server; + try { + assertPermission(role, permission, overrides); + } catch { + throw new ForbiddenException(`Missing permission: ${permission}`); + } + + return { + server, + role, + permissionOverrides: overrides, + }; } diff --git a/apps/api/src/servers/software/software.controller.ts b/apps/api/src/servers/software/software.controller.ts new file mode 100644 index 0000000..85de1fc --- /dev/null +++ b/apps/api/src/servers/software/software.controller.ts @@ -0,0 +1,40 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { reinstallServerSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { ServersService } from '../servers.service'; + +@ApiTags('servers') +@Controller('servers/:serverId/software') +@UseGuards(SessionGuard) +export class SoftwareController { + constructor(private readonly serversService: ServersService) {} + + @Post('reinstall') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Reinstall server software' }) + reinstall( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(reinstallServerSchema)) body: unknown, + ) { + return this.serversService.reinstallServer( + user.id, + serverId, + body as Parameters[2], + ); + } +} diff --git a/apps/api/src/servers/software/software.module.ts b/apps/api/src/servers/software/software.module.ts new file mode 100644 index 0000000..abceaf8 --- /dev/null +++ b/apps/api/src/servers/software/software.module.ts @@ -0,0 +1,12 @@ +import { forwardRef, Module } from '@nestjs/common'; + +import { AuthModule } from '../../auth/auth.module'; +import { ServersModule } from '../servers.module'; + +import { SoftwareController } from './software.controller'; + +@Module({ + imports: [AuthModule, forwardRef(() => ServersModule)], + controllers: [SoftwareController], +}) +export class SoftwareModule {} diff --git a/apps/api/src/support/support.controller.ts b/apps/api/src/support/support.controller.ts new file mode 100644 index 0000000..635fee8 --- /dev/null +++ b/apps/api/src/support/support.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + addSupportTicketMessageSchema, + createSupportTicketSchema, +} from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { SupportService } from './support.service'; + +@ApiTags('support') +@Controller('support/tickets') +@UseGuards(SessionGuard) +export class SupportController { + constructor(private readonly supportService: SupportService) {} + + @Post() + @ApiOperation({ summary: 'Create a support ticket' }) + create( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(createSupportTicketSchema)) body: unknown, + ) { + return this.supportService.create( + user.id, + body as Parameters[1], + ); + } + + @Get() + @ApiOperation({ summary: "List current user's support tickets" }) + list(@CurrentUser() user: { id: string }) { + return this.supportService.list(user.id); + } + + @Get(':ticketId') + @ApiOperation({ summary: 'Get a support ticket with messages' }) + get( + @CurrentUser() user: { id: string }, + @Param('ticketId', ParseUUIDPipe) ticketId: string, + ) { + return this.supportService.get(user.id, ticketId); + } + + @Post(':ticketId/messages') + @ApiOperation({ summary: 'Add a message to a support ticket' }) + addMessage( + @CurrentUser() user: { id: string }, + @Param('ticketId', ParseUUIDPipe) ticketId: string, + @Body(new ZodValidationPipe(addSupportTicketMessageSchema)) body: unknown, + ) { + return this.supportService.addMessage( + user.id, + ticketId, + body as Parameters[2], + ); + } +} diff --git a/apps/api/src/support/support.module.ts b/apps/api/src/support/support.module.ts new file mode 100644 index 0000000..11cff6a --- /dev/null +++ b/apps/api/src/support/support.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { SupportController } from './support.controller'; +import { SupportService } from './support.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [SupportController], + providers: [SupportService], + exports: [SupportService], +}) +export class SupportModule {} diff --git a/apps/api/src/support/support.service.ts b/apps/api/src/support/support.service.ts new file mode 100644 index 0000000..0979b68 --- /dev/null +++ b/apps/api/src/support/support.service.ts @@ -0,0 +1,143 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import type { + AddSupportTicketMessage, + CreateSupportTicket, + SupportTicket, + SupportTicketListResponse, +} from '@hexahost/contracts'; +import type { + SupportTicket as SupportTicketRow, + SupportTicketMessage, +} from '@hexahost/database'; + +import { PrismaService } from '../prisma/prisma.service'; + +function mapTicket( + ticket: SupportTicketRow & { messages?: SupportTicketMessage[] }, + includeMessages = false, +): SupportTicket { + return { + id: ticket.id, + subject: ticket.subject, + status: ticket.status, + priority: ticket.priority, + serverId: ticket.serverId, + createdAt: ticket.createdAt.toISOString(), + updatedAt: ticket.updatedAt.toISOString(), + ...(includeMessages && ticket.messages + ? { + messages: ticket.messages.map((message) => ({ + id: message.id, + authorId: message.authorId, + isInternal: message.isInternal, + body: message.body, + createdAt: message.createdAt.toISOString(), + })), + } + : {}), + }; +} + +@Injectable() +export class SupportService { + constructor(private readonly prisma: PrismaService) {} + + async create(userId: string, input: CreateSupportTicket): Promise { + if (input.serverId) { + const server = await this.prisma.gameServer.findFirst({ + where: { id: input.serverId, userId }, + }); + if (!server) { + throw new ForbiddenException('Server not found or not owned by user'); + } + } + + const ticket = await this.prisma.supportTicket.create({ + data: { + userId, + subject: input.subject.trim(), + priority: input.priority, + serverId: input.serverId, + messages: { + create: { + authorId: userId, + body: input.body.trim(), + }, + }, + }, + include: { messages: true }, + }); + + return mapTicket(ticket, true); + } + + async list(userId: string): Promise { + const tickets = await this.prisma.supportTicket.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + take: 100, + }); + + return { tickets: tickets.map((ticket) => mapTicket(ticket)) }; + } + + async get(userId: string, ticketId: string): Promise { + const ticket = await this.prisma.supportTicket.findFirst({ + where: { id: ticketId, userId }, + include: { + messages: { + where: { isInternal: false }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!ticket) { + throw new NotFoundException('Support ticket not found'); + } + + return mapTicket(ticket, true); + } + + async addMessage( + userId: string, + ticketId: string, + input: AddSupportTicketMessage, + ): Promise { + const ticket = await this.prisma.supportTicket.findFirst({ + where: { id: ticketId, userId }, + }); + + if (!ticket) { + throw new NotFoundException('Support ticket not found'); + } + + if (ticket.status === 'CLOSED' || ticket.status === 'RESOLVED') { + throw new ForbiddenException('Cannot add messages to a closed ticket'); + } + + await this.prisma.$transaction([ + this.prisma.supportTicketMessage.create({ + data: { + ticketId, + authorId: userId, + body: input.body.trim(), + }, + }), + this.prisma.supportTicket.update({ + where: { id: ticketId }, + data: { + status: ticket.status === 'WAITING' ? 'OPEN' : ticket.status, + updatedAt: new Date(), + }, + }), + ]); + + return this.get(userId, ticketId); + } +} diff --git a/apps/docs/README.md b/apps/docs/README.md new file mode 100644 index 0000000..ec2fc93 --- /dev/null +++ b/apps/docs/README.md @@ -0,0 +1,28 @@ +# HexaHost GameCloud — static docs site + +Minimal static documentation hub with links to the live API docs and key product pages. + +## Serve locally + +From the repository root: + +```bash +pnpm install +pnpm --filter @hexahost/docs dev +``` + +Or from this directory: + +```bash +pnpm install +pnpm dev +``` + +The site is served at http://localhost:4000. + +## Prerequisites for linked resources + +- **Web app** (`http://localhost:3000`) — run `pnpm --filter @hexahost/web dev` +- **API Swagger** (`http://localhost:3001/api/v1/docs`) — run `pnpm --filter @hexahost/api dev` + +Repository markdown under `docs/` is referenced by path; open those files in your editor or on GitHub. diff --git a/apps/docs/index.html b/apps/docs/index.html new file mode 100644 index 0000000..5dcaef1 --- /dev/null +++ b/apps/docs/index.html @@ -0,0 +1,130 @@ + + + + + + HexaHost GameCloud Documentation + + + +
+

HexaHost GameCloud

+

Developer documentation hub for the GameCloud control plane.

+ +
+

API

+ +
+ +
+

Key features

+ +
+ +
+

Repository docs

+
    +
  • docs/api/README.md — API authentication and versioning
  • +
  • docs/operations/ — backups, upgrades, node drain, disaster recovery
  • +
  • docs/integrations/whmcs/ — WHMCS module installation and security
  • +
  • docs/security/ — container isolation, secrets, data retention
  • +
+
+
+ + diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..5b301bc --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,14 @@ +{ + "name": "@hexahost/docs", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "serve . -p 4000", + "start": "serve . -p 4000", + "lint": "node -e \"process.exit(0)\"", + "typecheck": "node -e \"process.exit(0)\"" + }, + "devDependencies": { + "serve": "^14.2.4" + } +} diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..fbc605c --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hexahost/e2e", + "version": "0.0.1", + "private": true, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "lint": "tsc --noEmit", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@hexahost/typescript-config": "workspace:*", + "@playwright/test": "^1.51.1", + "@types/node": "^22.13.10", + "typescript": "^5.7.3" + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 0000000..e3bb539 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: appUrl, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/apps/e2e/tests/smoke.spec.ts b/apps/e2e/tests/smoke.spec.ts new file mode 100644 index 0000000..c19de82 --- /dev/null +++ b/apps/e2e/tests/smoke.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test("home page loads", async ({ page }) => { + const response = await page.goto("/"); + expect(response?.ok()).toBeTruthy(); + await expect(page.locator("h1")).toBeVisible(); +}); + +test("login page loads", async ({ page }) => { + const response = await page.goto("/login"); + expect(response?.ok()).toBeTruthy(); + await expect(page.getByRole("heading", { name: /Anmelden|Sign in|Log in/i })).toBeVisible(); + await expect(page.getByLabel(/E-Mail|Email/i)).toBeVisible(); + await expect(page.getByLabel(/^Passwort$|^Password$/i)).toBeVisible(); +}); + +test("register page loads", async ({ page }) => { + const response = await page.goto("/register"); + expect(response?.ok()).toBeTruthy(); + await expect( + page.getByRole("heading", { name: /Konto erstellen|Create account/i }), + ).toBeVisible(); + await expect(page.getByLabel(/E-Mail|Email/i)).toBeVisible(); + await expect(page.getByLabel(/^Passwort$|^Password$/i)).toBeVisible(); +}); + +test("docs page loads", async ({ page }) => { + const response = await page.goto("/docs"); + expect(response?.ok()).toBeTruthy(); + await expect( + page.getByRole("heading", { name: /Dokumentation|Documentation/i }), + ).toBeVisible(); +}); + +test("API health if reachable", async ({ request }) => { + const apiUrl = process.env.API_URL ?? "http://localhost:3001"; + + let response; + try { + response = await request.get(`${apiUrl}/api/v1/health/live`, { timeout: 3000 }); + } catch { + test.info().annotations.push({ + type: "skip-reason", + description: "API not reachable", + }); + return; + } + + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { status?: string }; + expect(body.status).toBe("ok"); +}); diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 0000000..5bf6e2c --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@hexahost/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "types": ["node", "@playwright/test"] + }, + "include": ["**/*.ts"] +} diff --git a/apps/node-agent/internal/archive/archive.go b/apps/node-agent/internal/archive/archive.go index 1da0b27..a46bdb0 100644 --- a/apps/node-agent/internal/archive/archive.go +++ b/apps/node-agent/internal/archive/archive.go @@ -95,6 +95,99 @@ func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, er return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil } +// CreateTarGzFromPaths archives one or more paths under dataRoot into outputPath. +func CreateTarGzFromPaths(dataRoot string, relativePaths []string, outputPath string) (sha256sum string, size int64, err error) { + dataRoot = filepath.Clean(dataRoot) + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return "", 0, err + } + + file, err := os.Create(outputPath) + if err != nil { + return "", 0, err + } + defer file.Close() + + hasher := sha256.New() + writer := io.MultiWriter(file, hasher) + gzipWriter := gzip.NewWriter(writer) + tarWriter := tar.NewWriter(gzipWriter) + + for _, rel := range relativePaths { + abs := filepath.Join(dataRoot, filepath.FromSlash(rel)) + abs = filepath.Clean(abs) + if !strings.HasPrefix(abs, dataRoot+string(os.PathSeparator)) && abs != dataRoot { + return "", 0, fmt.Errorf("path escapes data root: %s", rel) + } + + info, err := os.Stat(abs) + if err != nil { + return "", 0, err + } + + baseName := filepath.Base(abs) + if info.IsDir() { + err = filepath.Walk(abs, func(path string, entry os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + innerRel, err := filepath.Rel(abs, path) + if err != nil { + return err + } + if innerRel == "." { + return nil + } + headerName := filepath.ToSlash(filepath.Join(baseName, innerRel)) + return writeTarEntry(tarWriter, path, entry, headerName) + }) + if err != nil { + return "", 0, err + } + continue + } + + if err := writeTarEntry(tarWriter, abs, info, baseName); err != nil { + return "", 0, err + } + } + + if err := tarWriter.Close(); err != nil { + return "", 0, err + } + if err := gzipWriter.Close(); err != nil { + return "", 0, err + } + + stat, err := file.Stat() + if err != nil { + return "", 0, err + } + + return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil +} + +func writeTarEntry(tarWriter *tar.Writer, path string, entry os.FileInfo, headerName string) error { + header, err := tar.FileInfoHeader(entry, "") + if err != nil { + return err + } + header.Name = headerName + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + if entry.IsDir() { + return nil + } + sourceFile, err := os.Open(path) + if err != nil { + return err + } + defer sourceFile.Close() + _, err = io.Copy(tarWriter, sourceFile) + return err +} + // ExtractTarGz extracts archivePath into destDir. func ExtractTarGz(archivePath, destDir string) error { destDir = filepath.Clean(destDir) diff --git a/apps/node-agent/internal/docker/client.go b/apps/node-agent/internal/docker/client.go index f77518e..7dd06f5 100644 --- a/apps/node-agent/internal/docker/client.go +++ b/apps/node-agent/internal/docker/client.go @@ -113,9 +113,19 @@ func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServer Target: "/data", }}, Resources: container.Resources{ - Memory: int64(spec.RamMb) * 1024 * 1024, + Memory: int64(spec.RamMb) * 1024 * 1024, + PidsLimit: ptrInt64(256), + }, + Privileged: false, + CapDrop: []string{"ALL"}, + SecurityOpt: []string{"no-new-privileges:true"}, + LogConfig: container.LogConfig{ + Type: "json-file", + Config: map[string]string{ + "max-size": "10m", + "max-file": "3", + }, }, - Privileged: false, }, nil, nil, @@ -198,6 +208,10 @@ func mapSoftwareFamily(family string) string { } } +func ptrInt64(value int64) *int64 { + return &value +} + // StreamLogs streams container logs and invokes callback for each line. func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error { opts := container.LogsOptions{ diff --git a/apps/node-agent/internal/files/safe.go b/apps/node-agent/internal/files/safe.go index 8b86184..1cbc62c 100644 --- a/apps/node-agent/internal/files/safe.go +++ b/apps/node-agent/internal/files/safe.go @@ -229,6 +229,31 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error { return nil } +// RemovePath deletes a file or directory relative to dataRoot. +func RemovePath(dataRoot, relativePath string) error { + target, err := ResolvePath(dataRoot, relativePath) + if err != nil { + return err + } + if err := rejectSymlinks(dataRoot, target); err != nil { + return err + } + + info, err := os.Lstat(target) + if err != nil { + if os.IsNotExist(err) { + return ErrNotFound + } + return err + } + + if info.IsDir() { + return os.RemoveAll(target) + } + + return os.Remove(target) +} + // DeleteFile removes a file relative to dataRoot. func DeleteFile(dataRoot, relativePath string) error { filePath, err := ResolvePath(dataRoot, relativePath) diff --git a/apps/node-agent/internal/protocol/messages.go b/apps/node-agent/internal/protocol/messages.go index a4e46e2..2ca273c 100644 --- a/apps/node-agent/internal/protocol/messages.go +++ b/apps/node-agent/internal/protocol/messages.go @@ -38,6 +38,9 @@ const ( TypeServerFilesList MessageType = "server.files.list" TypeServerFilesRead MessageType = "server.files.read" TypeServerFilesWrite MessageType = "server.files.write" + TypeServerFilesDelete MessageType = "server.files.delete" + TypeServerFilesArchive MessageType = "server.files.archive" + TypeServerFilesUnarchive MessageType = "server.files.unarchive" TypeServerFilesUploadDone MessageType = "server.files.upload.complete" TypeServerWorldValidate MessageType = "server.world.validate" TypeServerWorldArchive MessageType = "server.world.archive" @@ -76,6 +79,9 @@ var controlToAgentTypes = map[MessageType]struct{}{ TypeServerFilesList: {}, TypeServerFilesRead: {}, TypeServerFilesWrite: {}, + TypeServerFilesDelete: {}, + TypeServerFilesArchive: {}, + TypeServerFilesUnarchive: {}, TypeServerFilesUploadDone: {}, TypeServerWorldValidate: {}, TypeServerWorldArchive: {}, @@ -185,9 +191,30 @@ type ServerFilesReadPayload struct { // ServerFilesWritePayload writes content to a file in the server data directory. type ServerFilesWritePayload struct { OperationMeta - Path string `json:"path"` - Content string `json:"content"` - Create bool `json:"create"` + Path string `json:"path"` + Content string `json:"content"` + Create bool `json:"create"` + Encoding string `json:"encoding,omitempty"` +} + +// ServerFilesDeletePayload deletes a file or directory relative to data root. +type ServerFilesDeletePayload struct { + OperationMeta + Path string `json:"path"` +} + +// ServerFilesArchivePayload creates a tar.gz archive from paths. +type ServerFilesArchivePayload struct { + OperationMeta + Paths []string `json:"paths"` + ArchiveName string `json:"archiveName,omitempty"` +} + +// ServerFilesUnarchivePayload extracts a tar.gz archive. +type ServerFilesUnarchivePayload struct { + OperationMeta + ArchivePath string `json:"archivePath"` + DestinationPath string `json:"destinationPath"` } // ServerWorldValidatePayload validates a Minecraft world directory. diff --git a/apps/node-agent/internal/runtime/manager.go b/apps/node-agent/internal/runtime/manager.go index cb96ce0..87327bc 100644 --- a/apps/node-agent/internal/runtime/manager.go +++ b/apps/node-agent/internal/runtime/manager.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "os" "path/filepath" + "strings" "sync" + "time" "github.com/hexahost/gamecloud/node-agent/internal/archive" "github.com/hexahost/gamecloud/node-agent/internal/docker" @@ -357,6 +360,88 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa }) } +// HandleFilesDelete removes a file or directory from the server data directory. +func (m *Manager) HandleFilesDelete(ctx context.Context, correlationID string, payload protocol.ServerFilesDeletePayload) { + meta := payload.OperationMeta + rec, ok := m.getServer(meta.ServerID) + if !ok { + m.failOperation(ctx, correlationID, meta, "files.delete", "SERVER_NOT_FOUND", "server is not provisioned on this node") + return + } + + if err := files.RemovePath(rec.DataPath, payload.Path); err != nil { + m.failOperation(ctx, correlationID, meta, "files.delete", filesErrorCode(err), err.Error()) + return + } + + m.completeOperation(ctx, correlationID, meta, "files.delete", map[string]string{ + "path": payload.Path, + }) +} + +// HandleFilesArchive creates a tar.gz archive from selected paths. +func (m *Manager) HandleFilesArchive(ctx context.Context, correlationID string, payload protocol.ServerFilesArchivePayload) { + meta := payload.OperationMeta + rec, ok := m.getServer(meta.ServerID) + if !ok { + m.failOperation(ctx, correlationID, meta, "files.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node") + return + } + + archiveName := payload.ArchiveName + if archiveName == "" { + archiveName = fmt.Sprintf("archive-%d.tar.gz", time.Now().Unix()) + } + if !strings.HasSuffix(archiveName, ".tar.gz") { + archiveName += ".tar.gz" + } + + outputPath := filepath.Join(rec.DataPath, ".hexahost-archives", archiveName) + checksum, size, err := archive.CreateTarGzFromPaths(rec.DataPath, payload.Paths, outputPath) + if err != nil { + m.failOperation(ctx, correlationID, meta, "files.archive", "ARCHIVE_FAILED", err.Error()) + return + } + + rel, _ := filepath.Rel(rec.DataPath, outputPath) + m.completeOperation(ctx, correlationID, meta, "files.archive", map[string]any{ + "archivePath": filepath.ToSlash(rel), + "sha256": checksum, + "size": size, + }) +} + +// HandleFilesUnarchive extracts a tar.gz archive into a destination path. +func (m *Manager) HandleFilesUnarchive(ctx context.Context, correlationID string, payload protocol.ServerFilesUnarchivePayload) { + meta := payload.OperationMeta + rec, ok := m.getServer(meta.ServerID) + if !ok { + m.failOperation(ctx, correlationID, meta, "files.unarchive", "SERVER_NOT_FOUND", "server is not provisioned on this node") + return + } + + archiveAbs, err := files.ResolvePath(rec.DataPath, payload.ArchivePath) + if err != nil { + m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error()) + return + } + + destAbs, err := files.ResolvePath(rec.DataPath, payload.DestinationPath) + if err != nil { + m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error()) + return + } + + if err := archive.ExtractTarGz(archiveAbs, destAbs); err != nil { + m.failOperation(ctx, correlationID, meta, "files.unarchive", "UNARCHIVE_FAILED", err.Error()) + return + } + + m.completeOperation(ctx, correlationID, meta, "files.unarchive", map[string]string{ + "destinationPath": payload.DestinationPath, + }) +} + // HandleBackupPrepare runs save-off and save-all flush via RCON. func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) { rec, ok := m.getServer(meta.ServerID) diff --git a/apps/node-agent/internal/ws/client.go b/apps/node-agent/internal/ws/client.go index de2de65..6de1233 100644 --- a/apps/node-agent/internal/ws/client.go +++ b/apps/node-agent/internal/ws/client.go @@ -314,6 +314,30 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) { } c.runtime.HandleFilesWrite(ctx, env.MessageID, payload) + case protocol.TypeServerFilesDelete: + var payload protocol.ServerFilesDeletePayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + c.log.Warn("decode server.files.delete", "error", err) + return + } + c.runtime.HandleFilesDelete(ctx, env.MessageID, payload) + + case protocol.TypeServerFilesArchive: + var payload protocol.ServerFilesArchivePayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + c.log.Warn("decode server.files.archive", "error", err) + return + } + c.runtime.HandleFilesArchive(ctx, env.MessageID, payload) + + case protocol.TypeServerFilesUnarchive: + var payload protocol.ServerFilesUnarchivePayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + c.log.Warn("decode server.files.unarchive", "error", err) + return + } + c.runtime.HandleFilesUnarchive(ctx, env.MessageID, payload) + case protocol.TypeServerBackupPrepare: var meta protocol.OperationMeta if err := json.Unmarshal(env.Payload, &meta); err != nil { diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 97baa26..71ad2b6 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -15,10 +15,19 @@ "cancel": "Abbrechen", "back": "Zurück", "footer": "Selbstgehostete Minecraft-Server-Plattform.", - "servers": "Server" + "servers": "Server", + "features": "Funktionen", + "pricing": "Preise", + "docs": "Dokumentation", + "status": "Status", + "impressum": "Impressum", + "privacy": "Datenschutz", + "terms": "AGB", + "admin": "Admin", + "phaseBadge": "MVP · Phasen 0–9" }, "landing": { - "badge": "Phase 0 · Control Plane", + "badge": "MVP · Phase 9 abgeschlossen", "title": "Minecraft-Server on demand", "subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.", "ctaPrimary": "Jetzt starten", @@ -205,7 +214,36 @@ "players": "Spieler", "worlds": "Welten", "backups": "Backups", - "addons": "Add-ons" + "addons": "Add-ons", + "software": "Software", + "schedules": "Zeitpläne", + "access": "Zugriffe" + }, + "wizard": { + "stepsLabel": "Erstellungsschritte", + "steps": { + "basics": "Grundlagen", + "software": "Software", + "review": "Prüfen" + }, + "next": "Weiter", + "back": "Zurück" + }, + "software": { + "title": "Software", + "description": "Installierte Server-Software und Version.", + "loading": "Wird geladen…", + "loadError": "Software-Informationen konnten nicht geladen werden.", + "family": "Software-Familie", + "version": "Minecraft-Version", + "edition": "Edition", + "changeHint": "Software-Wechsel wird in einer zukünftigen Version unterstützt.", + "reinstall": "Software neu installieren", + "reinstalling": "Wird neu installiert…", + "reinstallConfirm": "Server-Software wirklich neu installieren? Zuerst wird ein Backup erstellt.", + "reinstallStarted": "Neuinstallation gestartet. Der Server ist während der Installation nicht verfügbar.", + "reinstallError": "Neuinstallation konnte nicht gestartet werden.", + "reinstallStoppedHint": "Stoppen Sie den Server, bevor Sie die Software neu installieren." }, "console": { "loading": "Wird geladen…", @@ -233,10 +271,27 @@ "save": "Speichern", "saving": "Wird gespeichert…", "saved": "Datei gespeichert.", + "upload": "Hochladen", + "uploading": "Wird hochgeladen…", + "uploaded": "Datei hochgeladen.", + "delete": "Löschen", + "deleted": "Gelöscht.", + "deleteConfirm": "{name} wirklich löschen?", + "archive": "Auswahl archivieren", + "archiving": "Wird archiviert…", + "archived": "Archiv erstellt.", + "unarchive": "Archiv entpacken", + "unarchiving": "Wird entpackt…", + "unarchived": "Archiv-Entpackung gestartet.", + "unarchiveConfirm": "{name} ins aktuelle Verzeichnis entpacken?", "errors": { "loadFailed": "Verzeichnis konnte nicht geladen werden.", "readFailed": "Datei konnte nicht gelesen werden.", - "saveFailed": "Datei konnte nicht gespeichert werden." + "saveFailed": "Datei konnte nicht gespeichert werden.", + "deleteFailed": "Datei konnte nicht gelöscht werden.", + "uploadFailed": "Datei konnte nicht hochgeladen werden.", + "archiveFailed": "Archiv konnte nicht erstellt werden.", + "unarchiveFailed": "Archiv konnte nicht entpackt werden." } }, "properties": { @@ -280,14 +335,32 @@ "loading": "Wird geladen…", "onlineCount": "Spieler online", "whitelistCount": "Whitelist-Einträge", + "operatorsCount": "Operatoren", + "onlineTitle": "Spieler online", + "whitelistTitle": "Whitelist", + "operatorsTitle": "Operatoren", + "bansTitle": "Gebannte Spieler", + "bansEmpty": "Keine gebannten Spieler.", "empty": "Keine Spieler online.", "retry": "Erneut versuchen", + "kick": "Kicken", + "addWhitelist": "Zur Whitelist hinzufügen", + "addOperator": "Operator hinzufügen", + "addBan": "Spieler bannen", + "remove": "Entfernen", + "fields": { + "name": "Spielername", + "uuid": "UUID", + "reason": "Grund" + }, "columns": { "name": "Spieler", - "ping": "Ping" + "ping": "Ping", + "actions": "Aktionen" }, "errors": { - "loadFailed": "Spielerliste konnte nicht geladen werden." + "loadFailed": "Spielerliste konnte nicht geladen werden.", + "actionFailed": "Aktion fehlgeschlagen." } }, "worlds": { @@ -347,6 +420,473 @@ "remove": "Entfernen", "installed": "Installiert", "empty": "Noch keine Add-ons installiert." + }, + "schedules": { + "title": "Zeitpläne", + "description": "Geplante Start-, Stopp- und Wartungsaktionen.", + "loading": "Wird geladen…", + "empty": "Noch keine Zeitpläne vorhanden.", + "create": "Zeitplan erstellen", + "edit": "Bearbeiten", + "save": "Änderungen speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "saving": "Wird gespeichert…", + "deleteConfirm": "Zeitplan \"{name}\" wirklich löschen?", + "fields": { + "name": "Name", + "cron": "Cron-Ausdruck", + "timezone": "Zeitzone", + "taskType": "Aufgabe", + "enabled": "Aktiv", + "command": "Konsolenbefehl", + "commandPlaceholder": "say Server startet neu" + }, + "columns": { + "name": "Name", + "cron": "Cron", + "task": "Aufgabe", + "status": "Status", + "actions": "Aktionen" + }, + "status": { + "enabled": "Aktiv", + "disabled": "Inaktiv" + }, + "taskTypes": { + "START": "Server starten", + "STOP": "Server stoppen", + "RESTART": "Server neu starten", + "BACKUP": "Backup erstellen", + "CONSOLE_COMMAND": "Befehl ausführen", + "UPDATE_CHECK": "Updates prüfen", + "MAINTENANCE": "Wartung" + }, + "errors": { + "loadFailed": "Zeitpläne konnten nicht geladen werden.", + "saveFailed": "Zeitplan konnte nicht gespeichert werden.", + "deleteFailed": "Zeitplan konnte nicht gelöscht werden." + } + }, + "access": { + "title": "Zugriffe", + "description": "Freigaben und Berechtigungen für diesen Server.", + "loading": "Wird geladen…", + "empty": "Noch keine Mitglieder. Laden Sie jemanden ein, um diesen Server zu teilen.", + "invite": "Mitglied einladen", + "sendInvite": "Einladung senden", + "inviting": "Wird gesendet…", + "cancel": "Abbrechen", + "remove": "Entfernen", + "inviteSuccess": "Mitglied erfolgreich eingeladen.", + "invitePending": "Einladung vermerkt. Der Benutzer muss sich registrieren, bevor er auf diesen Server zugreifen kann.", + "removeConfirm": "{name} von diesem Server entfernen?", + "fields": { + "email": "E-Mail-Adresse", + "emailPlaceholder": "benutzer@beispiel.de", + "role": "Rolle" + }, + "columns": { + "member": "Mitglied", + "email": "E-Mail", + "role": "Rolle", + "status": "Status", + "actions": "Aktionen" + }, + "roles": { + "OWNER": "Inhaber", + "ADMIN": "Administrator", + "OPERATOR": "Operator", + "DEVELOPER": "Entwickler", + "VIEWER": "Betrachter" + }, + "status": { + "active": "Aktiv", + "pending": "Ausstehend" + }, + "errors": { + "loadFailed": "Mitglieder konnten nicht geladen werden.", + "inviteFailed": "Einladung konnte nicht gesendet werden.", + "removeFailed": "Mitglied konnte nicht entfernt werden." + } + } + }, + "account": { + "nav": { + "label": "Kontonavigation", + "dashboard": "Dashboard", + "servers": "Server", + "billing": "Abrechnung", + "profile": "Profil", + "notifications": "Benachrichtigungen", + "security": "Sicherheit" + }, + "billing": { + "title": "Abrechnung", + "subtitle": "Credits und Transaktionsverlauf.", + "loading": "Wird geladen…", + "loadError": "Abrechnungsdaten konnten nicht geladen werden.", + "balanceTitle": "Aktuelles Guthaben", + "periodStart": "Abrechnungszeitraum ab {date}", + "creditsLabel": "Verbleibende Credits", + "transactionsTitle": "Transaktionen", + "transactionsEmpty": "Noch keine Transaktionen in diesem Zeitraum.", + "columns": { + "date": "Datum", + "type": "Typ", + "amount": "Betrag", + "reference": "Referenz" + } + }, + "profile": { + "title": "Profil", + "subtitle": "Ihre Kontoinformationen.", + "loading": "Wird geladen…", + "notSignedIn": "Nicht angemeldet.", + "accountInfo": "Kontodaten", + "accountInfoDescription": "Profilinformationen aus Ihrem Konto.", + "fields": { + "email": "E-Mail", + "username": "Benutzername", + "displayName": "Anzeigename", + "emailVerified": "E-Mail verifiziert", + "twoFactor": "Zwei-Faktor-Auth" + }, + "yes": "Ja", + "no": "Nein", + "enabled": "Aktiv", + "disabled": "Inaktiv", + "securityHint": "Passwort, 2FA und Sitzungen verwalten unter", + "securityLink": "Sicherheit" + }, + "notifications": { + "title": "Benachrichtigungen", + "subtitle": "E-Mail- und In-App-Benachrichtigungen.", + "loading": "Wird geladen…", + "listTitle": "Neueste Benachrichtigungen", + "unreadCount": "{count, plural, one {# ungelesene Benachrichtigung} other {# ungelesene Benachrichtigungen}}", + "allRead": "Alles gelesen.", + "markRead": "Als gelesen markieren", + "markAllRead": "Alle als gelesen markieren", + "emptyTitle": "Keine Benachrichtigungen", + "emptyDescription": "Sie haben noch keine Benachrichtigungen erhalten.", + "preferencesTitle": "Einstellungen", + "preferencesDescription": "Wählen Sie, welche Benachrichtigungen Sie pro Kanal erhalten.", + "enabled": "Aktiv", + "disabled": "Inaktiv", + "channels": { + "IN_APP": "In-App", + "EMAIL": "E-Mail", + "WEBHOOK": "Webhook", + "DISCORD": "Discord" + }, + "types": { + "SERVER_READY": "Server bereit", + "SERVER_START_FAILED": "Serverstart fehlgeschlagen", + "SERVER_STOPPED": "Server gestoppt", + "IDLE_STOP_WARNING": "Leerlauf-Stopp-Warnung", + "BACKUP_SUCCESS": "Backup erfolgreich", + "BACKUP_FAILED": "Backup fehlgeschlagen", + "RESTORE_SUCCESS": "Wiederherstellung erfolgreich", + "RESTORE_FAILED": "Wiederherstellung fehlgeschlagen", + "CREDITS_LOW": "Wenig Credits", + "BILLING_ISSUE": "Abrechnungsproblem", + "INVITE": "Einladung", + "NODE_INCIDENT": "Node-Vorfall", + "MAINTENANCE": "Wartung", + "SECURITY": "Sicherheit", + "NEW_LOGIN": "Neue Anmeldung", + "TWO_FA_CHANGED": "Zwei-Faktor geändert" + }, + "errors": { + "loadFailed": "Benachrichtigungen konnten nicht geladen werden.", + "markReadFailed": "Benachrichtigung konnte nicht aktualisiert werden.", + "preferencesFailed": "Einstellungen konnten nicht gespeichert werden." + } + } + }, + "admin": { + "backToPanel": "Zurück zum Panel", + "nav": { + "label": "Admin-Navigation", + "dashboard": "Dashboard", + "nodes": "Nodes", + "users": "Benutzer", + "jobs": "Jobs", + "audit": "Audit" + }, + "dashboard": { + "title": "Admin-Dashboard", + "subtitle": "Plattformweite Kennzahlen und Systemstatus.", + "loading": "Wird geladen…", + "loadError": "Dashboard-Daten konnten nicht geladen werden.", + "generatedAt": "Stand: {time}", + "stats": { + "activeUsers": "Aktive Benutzer", + "totalServers": "Server gesamt", + "runningServers": "Laufende Server", + "queueLength": "Warteschlange", + "onlineNodes": "Online-Nodes", + "failedJobs24h": "Fehlgeschlagene Jobs (24h)", + "openAbuseReports": "Offene Abuse-Reports", + "openTickets": "Offene Tickets" + } + }, + "nodes": { + "title": "Game-Nodes", + "subtitle": "Node-Status und Wartungsaktionen.", + "loading": "Wird geladen…", + "loadError": "Nodes konnten nicht geladen werden.", + "empty": "Keine Nodes registriert.", + "drain": "Drain", + "maintenance": "Wartung", + "exitMaintenance": "Wartung beenden", + "actionError": "Aktion fehlgeschlagen.", + "columns": { + "name": "Node", + "status": "Status", + "servers": "Server", + "ram": "RAM", + "actions": "Aktionen" + } + }, + "users": { + "title": "Benutzer", + "subtitle": "Benutzerkonten suchen und verwalten.", + "loading": "Wird geladen…", + "loadError": "Benutzer konnten nicht geladen werden.", + "empty": "Keine Benutzer gefunden.", + "searchPlaceholder": "E-Mail oder Benutzername…", + "search": "Suchen", + "suspend": "Sperren", + "unsuspend": "Entsperren", + "actionError": "Aktion fehlgeschlagen.", + "columns": { + "user": "Benutzer", + "status": "Status", + "roles": "Rollen", + "servers": "Server", + "actions": "Aktionen" + } + }, + "jobs": { + "title": "Hintergrund-Jobs", + "subtitle": "Aktuelle und kürzlich abgeschlossene Queue-Jobs.", + "loading": "Wird geladen…", + "loadError": "Jobs konnten nicht geladen werden.", + "empty": "Keine Jobs gefunden.", + "columns": { + "queue": "Queue", + "job": "Job", + "status": "Status", + "attempts": "Versuche", + "created": "Erstellt" + } + }, + "audit": { + "title": "Audit-Log", + "subtitle": "Plattformweite Sicherheits- und Lifecycle-Ereignisse.", + "loading": "Wird geladen…", + "loadError": "Audit-Ereignisse konnten nicht geladen werden.", + "empty": "Keine Ereignisse gefunden.", + "loadMore": "Mehr laden", + "columns": { + "time": "Zeit", + "action": "Aktion", + "user": "Benutzer", + "entity": "Entität" + } + } + }, + "public": { + "pricing": { + "title": "Preise", + "description": "Transparente Tarife für Minecraft-Server-Hosting.", + "cta": "Jetzt registrieren →", + "tiers": { + "starter": { + "name": "Starter", + "description": "Ideal zum Ausprobieren.", + "price": "Kostenlos", + "features": ["2 GB RAM", "1 Server", "Idle-Shutdown", "Community-Support"] + }, + "standard": { + "name": "Standard", + "description": "Für kleine Communities.", + "price": "Credits", + "features": ["4 GB RAM", "3 Server", "Backups", "Modrinth Add-ons"] + }, + "pro": { + "name": "Pro", + "description": "Für anspruchsvolle Welten.", + "price": "Credits", + "features": ["8+ GB RAM", "Unbegrenzte Server*", "Prioritäts-Queue", "WHMCS-Integration"] + } + } + }, + "features": { + "title": "Funktionen", + "description": "Alles für professionelles Minecraft-Server-Hosting.", + "items": { + "control": { + "title": "Zentrale Steuerung", + "description": "Lifecycle, Konsole, Dateien und Properties über ein einheitliches Panel." + }, + "nodes": { + "title": "Multi-Node", + "description": "Horizontale Skalierung mit Agent-basierter Orchestrierung." + }, + "security": { + "title": "Sicherheit", + "description": "Sessions, 2FA, CSRF-Schutz und auditierbare Aktionen." + }, + "billing": { + "title": "Credit-Abrechnung", + "description": "Laufzeitbasierte Abrechnung mit transparentem Guthaben." + }, + "backups": { + "title": "Backups & Welten", + "description": "Manuelle und geplante Backups, Welt-Upload und -Download." + }, + "whmcs": { + "title": "WHMCS-Integration", + "description": "Provisioning, SSO und Abrechnung über WHMCS." + } + } + }, + "status": { + "title": "Systemstatus", + "description": "Aktueller Betriebsstatus der Plattformkomponenten.", + "operational": "Betriebsbereit", + "selfHostedNote": "Status basiert auf Self-Hosted-Deployment. Konfigurieren Sie Health-Checks für Live-Monitoring.", + "components": { + "api": { + "name": "API", + "description": "REST Control Plane unter /api/v1" + }, + "worker": { + "name": "Worker", + "description": "BullMQ Hintergrundverarbeitung" + }, + "panel": { + "name": "Web-Panel", + "description": "Next.js Benutzeroberfläche" + }, + "dns": { + "name": "DNS / Edge", + "description": "Join-to-Start und Edge-Gateway" + } + } + }, + "docs": { + "title": "Dokumentation", + "description": "Einstieg, API-Referenz und Betriebshinweise.", + "openapi": "Interaktive API-Dokumentation:", + "openapiLink": "OpenAPI / Swagger", + "cta": "Konto erstellen →", + "sections": { + "gettingStarted": { + "title": "Erste Schritte", + "body": "Registrieren Sie sich, verifizieren Sie Ihre E-Mail, erstellen Sie einen Server über den mehrstufigen Assistenten und starten Sie ihn über das Dashboard." + }, + "api": { + "title": "API", + "body": "Die REST-API unter /api/v1 bietet Endpunkte für Auth, Server-Lifecycle, Dateien, Backups und Admin-Funktionen. Authentifizierung erfolgt über Session-Cookies." + }, + "operations": { + "title": "Betrieb", + "body": "Deployen Sie Control Plane, Worker und Node-Agent über Docker Compose oder Ansible. Siehe docs/operations im Repository." + }, + "whmcs": { + "title": "WHMCS", + "body": "Das Server-Modul provisioniert Dienste automatisch. SSO ermöglicht nahtlosen Zugang aus dem WHMCS Client Area." + } + } + }, + "impressum": { + "title": "Impressum", + "description": "Angaben gemäß § 5 TMG.", + "notice": "Dies ist ein Impressums-Entwurf. Ersetzen Sie die Platzhalterangaben vor dem Produktivbetrieb.", + "sections": { + "operator": { + "title": "Anbieter", + "body": "HexaHost\nPlattform für Game-Server-Hosting" + }, + "address": { + "title": "Anschrift", + "body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nDeutschland" + }, + "contact": { + "title": "Kontakt", + "body": "E-Mail: kontakt@hexahost.example\nTelefon: +49 (0) 123 456789" + }, + "responsible": { + "title": "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV", + "body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt" + }, + "register": { + "title": "Registereintrag", + "body": "Registergericht: Amtsgericht Musterstadt\nRegisternummer: HRB 12345\nUSt-IdNr.: DE123456789" + }, + "dispute": { + "title": "Streitbeilegung", + "body": "Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen." + } + } + }, + "privacy": { + "title": "Datenschutzerklärung", + "description": "Informationen zur Verarbeitung personenbezogener Daten.", + "sections": { + "controller": { + "title": "Verantwortlicher", + "body": "Der Betreiber dieser selbstgehosteten Instanz ist für die Datenverarbeitung verantwortlich. Passen Sie diese Erklärung an Ihre Organisation an." + }, + "data": { + "title": "Erhobene Daten", + "body": "Wir verarbeiten Kontodaten (E-Mail, Benutzername), Sitzungsinformationen, Server-Metadaten und Nutzungsdaten zur Abrechnung." + }, + "cookies": { + "title": "Cookies", + "body": "Session-Cookies (hgc_session) und CSRF-Cookies (hgc_csrf) sind für die Authentifizierung erforderlich." + }, + "rights": { + "title": "Ihre Rechte", + "body": "Sie haben das Recht auf Auskunft, Berichtigung, Löschung und Datenübertragbarkeit gemäß DSGVO." + }, + "contact": { + "title": "Kontakt", + "body": "Wenden Sie sich für Datenschutzanfragen an den in Ihrem Impressum genannten Betreiber." + } + } + }, + "terms": { + "title": "Allgemeine Geschäftsbedingungen", + "description": "Nutzungsbedingungen der Plattform.", + "sections": { + "scope": { + "title": "Geltungsbereich", + "body": "Diese AGB gelten für die Nutzung der Minecraft-Server-Hosting-Plattform durch registrierte Benutzer." + }, + "usage": { + "title": "Nutzung", + "body": "Server dürfen nur für legale Zwecke betrieben werden. Missbrauch kann zur Sperrung führen." + }, + "billing": { + "title": "Abrechnung", + "body": "Credits werden nach Laufzeit verbraucht. Guthaben ist nicht übertragbar, sofern nicht anders vereinbart." + }, + "liability": { + "title": "Haftung", + "body": "Der Betreiber haftet nur bei Vorsatz und grober Fahrlässigkeit. Datenverluste sind durch eigene Backups abzusichern." + }, + "changes": { + "title": "Änderungen", + "body": "Der Betreiber kann diese Bedingungen mit angemessener Frist ändern. Fortgesetzte Nutzung gilt als Zustimmung." + } + } } } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8635d08..4c5ab38 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -15,10 +15,19 @@ "cancel": "Cancel", "back": "Back", "footer": "Self-hosted Minecraft server platform.", - "servers": "Servers" + "servers": "Servers", + "features": "Features", + "pricing": "Pricing", + "docs": "Documentation", + "status": "Status", + "impressum": "Legal notice", + "privacy": "Privacy", + "terms": "Terms", + "admin": "Admin", + "phaseBadge": "MVP · Phases 0–9" }, "landing": { - "badge": "Phase 0 · Control Plane", + "badge": "MVP · Phase 9 complete", "title": "Minecraft servers on demand", "subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.", "ctaPrimary": "Get started", @@ -205,7 +214,36 @@ "players": "Players", "worlds": "Worlds", "backups": "Backups", - "addons": "Add-ons" + "addons": "Add-ons", + "software": "Software", + "schedules": "Schedules", + "access": "Access" + }, + "wizard": { + "stepsLabel": "Creation steps", + "steps": { + "basics": "Basics", + "software": "Software", + "review": "Review" + }, + "next": "Next", + "back": "Back" + }, + "software": { + "title": "Software", + "description": "Installed server software and version.", + "loading": "Loading…", + "loadError": "Could not load software information.", + "family": "Software family", + "version": "Minecraft version", + "edition": "Edition", + "changeHint": "Software switching will be supported in a future release.", + "reinstall": "Reinstall software", + "reinstalling": "Reinstalling…", + "reinstallConfirm": "Reinstall server software? A backup will be created first.", + "reinstallStarted": "Reinstall started. The server will be unavailable during installation.", + "reinstallError": "Could not start reinstall.", + "reinstallStoppedHint": "Stop the server before reinstalling software." }, "console": { "loading": "Loading…", @@ -233,10 +271,27 @@ "save": "Save", "saving": "Saving…", "saved": "File saved.", + "upload": "Upload", + "uploading": "Uploading…", + "uploaded": "File uploaded.", + "delete": "Delete", + "deleted": "Deleted.", + "deleteConfirm": "Delete {name}?", + "archive": "Archive selected", + "archiving": "Archiving…", + "archived": "Archive created.", + "unarchive": "Extract archive", + "unarchiving": "Extracting…", + "unarchived": "Archive extraction started.", + "unarchiveConfirm": "Extract {name} into the current directory?", "errors": { "loadFailed": "Could not load directory.", "readFailed": "Could not read file.", - "saveFailed": "Could not save file." + "saveFailed": "Could not save file.", + "deleteFailed": "Could not delete file.", + "uploadFailed": "Could not upload file.", + "archiveFailed": "Could not create archive.", + "unarchiveFailed": "Could not extract archive." } }, "properties": { @@ -280,14 +335,32 @@ "loading": "Loading…", "onlineCount": "Online players", "whitelistCount": "Whitelist entries", + "operatorsCount": "Operators", + "onlineTitle": "Online players", + "whitelistTitle": "Whitelist", + "operatorsTitle": "Operators", + "bansTitle": "Banned players", + "bansEmpty": "No banned players.", "empty": "No players online.", "retry": "Retry", + "kick": "Kick", + "addWhitelist": "Add to whitelist", + "addOperator": "Add operator", + "addBan": "Ban player", + "remove": "Remove", + "fields": { + "name": "Player name", + "uuid": "UUID", + "reason": "Reason" + }, "columns": { "name": "Player", - "ping": "Ping" + "ping": "Ping", + "actions": "Actions" }, "errors": { - "loadFailed": "Could not load player list." + "loadFailed": "Could not load player list.", + "actionFailed": "Action failed." } }, "worlds": { @@ -347,6 +420,473 @@ "remove": "Remove", "installed": "Installed", "empty": "No add-ons installed yet." + }, + "schedules": { + "title": "Schedules", + "description": "Scheduled start, stop, and maintenance actions.", + "loading": "Loading…", + "empty": "No schedules yet.", + "create": "Create schedule", + "edit": "Edit", + "save": "Save changes", + "cancel": "Cancel", + "delete": "Delete", + "enable": "Enable", + "disable": "Disable", + "saving": "Saving…", + "deleteConfirm": "Delete schedule \"{name}\"?", + "fields": { + "name": "Name", + "cron": "Cron expression", + "timezone": "Timezone", + "taskType": "Task", + "enabled": "Enabled", + "command": "Console command", + "commandPlaceholder": "say Server restarting" + }, + "columns": { + "name": "Name", + "cron": "Cron", + "task": "Task", + "status": "Status", + "actions": "Actions" + }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "taskTypes": { + "START": "Start server", + "STOP": "Stop server", + "RESTART": "Restart server", + "BACKUP": "Create backup", + "CONSOLE_COMMAND": "Run command", + "UPDATE_CHECK": "Check for updates", + "MAINTENANCE": "Maintenance" + }, + "errors": { + "loadFailed": "Could not load schedules.", + "saveFailed": "Could not save schedule.", + "deleteFailed": "Could not delete schedule." + } + }, + "access": { + "title": "Access", + "description": "Sharing and permissions for this server.", + "loading": "Loading…", + "empty": "No members yet. Invite someone to share this server.", + "invite": "Invite member", + "sendInvite": "Send invite", + "inviting": "Sending…", + "cancel": "Cancel", + "remove": "Remove", + "inviteSuccess": "Member invited successfully.", + "invitePending": "Invite noted. The user must register before they can access this server.", + "removeConfirm": "Remove {name} from this server?", + "fields": { + "email": "Email address", + "emailPlaceholder": "user@example.com", + "role": "Role" + }, + "columns": { + "member": "Member", + "email": "Email", + "role": "Role", + "status": "Status", + "actions": "Actions" + }, + "roles": { + "OWNER": "Owner", + "ADMIN": "Admin", + "OPERATOR": "Operator", + "DEVELOPER": "Developer", + "VIEWER": "Viewer" + }, + "status": { + "active": "Active", + "pending": "Pending" + }, + "errors": { + "loadFailed": "Could not load members.", + "inviteFailed": "Could not send invite.", + "removeFailed": "Could not remove member." + } + } + }, + "account": { + "nav": { + "label": "Account navigation", + "dashboard": "Dashboard", + "servers": "Servers", + "billing": "Billing", + "profile": "Profile", + "notifications": "Notifications", + "security": "Security" + }, + "billing": { + "title": "Billing", + "subtitle": "Credits and transaction history.", + "loading": "Loading…", + "loadError": "Could not load billing data.", + "balanceTitle": "Current balance", + "periodStart": "Billing period from {date}", + "creditsLabel": "Credits remaining", + "transactionsTitle": "Transactions", + "transactionsEmpty": "No transactions in this period yet.", + "columns": { + "date": "Date", + "type": "Type", + "amount": "Amount", + "reference": "Reference" + } + }, + "profile": { + "title": "Profile", + "subtitle": "Your account information.", + "loading": "Loading…", + "notSignedIn": "Not signed in.", + "accountInfo": "Account details", + "accountInfoDescription": "Profile information from your account.", + "fields": { + "email": "Email", + "username": "Username", + "displayName": "Display name", + "emailVerified": "Email verified", + "twoFactor": "Two-factor auth" + }, + "yes": "Yes", + "no": "No", + "enabled": "Enabled", + "disabled": "Disabled", + "securityHint": "Manage password, 2FA, and sessions at", + "securityLink": "Security" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Email and in-app notifications.", + "loading": "Loading…", + "listTitle": "Recent notifications", + "unreadCount": "{count, plural, one {# unread notification} other {# unread notifications}}", + "allRead": "All caught up.", + "markRead": "Mark read", + "markAllRead": "Mark all read", + "emptyTitle": "No notifications", + "emptyDescription": "You have not received any notifications yet.", + "preferencesTitle": "Preferences", + "preferencesDescription": "Choose which notifications you receive on each channel.", + "enabled": "Enabled", + "disabled": "Disabled", + "channels": { + "IN_APP": "In-app", + "EMAIL": "Email", + "WEBHOOK": "Webhook", + "DISCORD": "Discord" + }, + "types": { + "SERVER_READY": "Server ready", + "SERVER_START_FAILED": "Server start failed", + "SERVER_STOPPED": "Server stopped", + "IDLE_STOP_WARNING": "Idle stop warning", + "BACKUP_SUCCESS": "Backup succeeded", + "BACKUP_FAILED": "Backup failed", + "RESTORE_SUCCESS": "Restore succeeded", + "RESTORE_FAILED": "Restore failed", + "CREDITS_LOW": "Low credits", + "BILLING_ISSUE": "Billing issue", + "INVITE": "Invite", + "NODE_INCIDENT": "Node incident", + "MAINTENANCE": "Maintenance", + "SECURITY": "Security", + "NEW_LOGIN": "New login", + "TWO_FA_CHANGED": "Two-factor changed" + }, + "errors": { + "loadFailed": "Could not load notifications.", + "markReadFailed": "Could not update notification.", + "preferencesFailed": "Could not save preferences." + } + } + }, + "admin": { + "backToPanel": "Back to panel", + "nav": { + "label": "Admin navigation", + "dashboard": "Dashboard", + "nodes": "Nodes", + "users": "Users", + "jobs": "Jobs", + "audit": "Audit" + }, + "dashboard": { + "title": "Admin dashboard", + "subtitle": "Platform-wide metrics and system status.", + "loading": "Loading…", + "loadError": "Could not load dashboard data.", + "generatedAt": "As of {time}", + "stats": { + "activeUsers": "Active users", + "totalServers": "Total servers", + "runningServers": "Running servers", + "queueLength": "Queue length", + "onlineNodes": "Online nodes", + "failedJobs24h": "Failed jobs (24h)", + "openAbuseReports": "Open abuse reports", + "openTickets": "Open tickets" + } + }, + "nodes": { + "title": "Game nodes", + "subtitle": "Node status and maintenance actions.", + "loading": "Loading…", + "loadError": "Could not load nodes.", + "empty": "No nodes registered.", + "drain": "Drain", + "maintenance": "Maintenance", + "exitMaintenance": "Exit maintenance", + "actionError": "Action failed.", + "columns": { + "name": "Node", + "status": "Status", + "servers": "Servers", + "ram": "RAM", + "actions": "Actions" + } + }, + "users": { + "title": "Users", + "subtitle": "Search and manage user accounts.", + "loading": "Loading…", + "loadError": "Could not load users.", + "empty": "No users found.", + "searchPlaceholder": "Email or username…", + "search": "Search", + "suspend": "Suspend", + "unsuspend": "Unsuspend", + "actionError": "Action failed.", + "columns": { + "user": "User", + "status": "Status", + "roles": "Roles", + "servers": "Servers", + "actions": "Actions" + } + }, + "jobs": { + "title": "Background jobs", + "subtitle": "Current and recently completed queue jobs.", + "loading": "Loading…", + "loadError": "Could not load jobs.", + "empty": "No jobs found.", + "columns": { + "queue": "Queue", + "job": "Job", + "status": "Status", + "attempts": "Attempts", + "created": "Created" + } + }, + "audit": { + "title": "Audit log", + "subtitle": "Platform-wide security and lifecycle events.", + "loading": "Loading…", + "loadError": "Could not load audit events.", + "empty": "No events found.", + "loadMore": "Load more", + "columns": { + "time": "Time", + "action": "Action", + "user": "User", + "entity": "Entity" + } + } + }, + "public": { + "pricing": { + "title": "Pricing", + "description": "Transparent plans for Minecraft server hosting.", + "cta": "Register now →", + "tiers": { + "starter": { + "name": "Starter", + "description": "Great for trying things out.", + "price": "Free", + "features": ["2 GB RAM", "1 server", "Idle shutdown", "Community support"] + }, + "standard": { + "name": "Standard", + "description": "For small communities.", + "price": "Credits", + "features": ["4 GB RAM", "3 servers", "Backups", "Modrinth add-ons"] + }, + "pro": { + "name": "Pro", + "description": "For demanding worlds.", + "price": "Credits", + "features": ["8+ GB RAM", "Unlimited servers*", "Priority queue", "WHMCS integration"] + } + } + }, + "features": { + "title": "Features", + "description": "Everything for professional Minecraft server hosting.", + "items": { + "control": { + "title": "Central control", + "description": "Lifecycle, console, files, and properties through a unified panel." + }, + "nodes": { + "title": "Multi-node", + "description": "Horizontal scaling with agent-based orchestration." + }, + "security": { + "title": "Security", + "description": "Sessions, 2FA, CSRF protection, and auditable actions." + }, + "billing": { + "title": "Credit billing", + "description": "Runtime-based billing with transparent balance." + }, + "backups": { + "title": "Backups & worlds", + "description": "Manual and scheduled backups, world upload and download." + }, + "whmcs": { + "title": "WHMCS integration", + "description": "Provisioning, SSO, and billing through WHMCS." + } + } + }, + "status": { + "title": "System status", + "description": "Current operational status of platform components.", + "operational": "Operational", + "selfHostedNote": "Status reflects self-hosted deployment. Configure health checks for live monitoring.", + "components": { + "api": { + "name": "API", + "description": "REST control plane at /api/v1" + }, + "worker": { + "name": "Worker", + "description": "BullMQ background processing" + }, + "panel": { + "name": "Web panel", + "description": "Next.js user interface" + }, + "dns": { + "name": "DNS / Edge", + "description": "Join-to-start and edge gateway" + } + } + }, + "docs": { + "title": "Documentation", + "description": "Getting started, API reference, and operations guides.", + "openapi": "Interactive API documentation:", + "openapiLink": "OpenAPI / Swagger", + "cta": "Create account →", + "sections": { + "gettingStarted": { + "title": "Getting started", + "body": "Register, verify your email, create a server through the multi-step wizard, and start it from the dashboard." + }, + "api": { + "title": "API", + "body": "The REST API at /api/v1 provides endpoints for auth, server lifecycle, files, backups, and admin functions. Authentication uses session cookies." + }, + "operations": { + "title": "Operations", + "body": "Deploy control plane, worker, and node agent via Docker Compose or Ansible. See docs/operations in the repository." + }, + "whmcs": { + "title": "WHMCS", + "body": "The server module provisions services automatically. SSO enables seamless access from the WHMCS client area." + } + } + }, + "impressum": { + "title": "Legal notice", + "description": "Information according to applicable law (German Impressum).", + "notice": "This is a template legal notice. Replace placeholder details before production use.", + "sections": { + "operator": { + "title": "Service provider", + "body": "HexaHost\nGame server hosting platform" + }, + "address": { + "title": "Address", + "body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nGermany" + }, + "contact": { + "title": "Contact", + "body": "Email: kontakt@hexahost.example\nPhone: +49 (0) 123 456789" + }, + "responsible": { + "title": "Responsible for content (§ 55 Abs. 2 RStV)", + "body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt" + }, + "register": { + "title": "Commercial register", + "body": "Register court: Amtsgericht Musterstadt\nRegistration number: HRB 12345\nVAT ID: DE123456789" + }, + "dispute": { + "title": "Dispute resolution", + "body": "We are not obliged or willing to participate in dispute resolution proceedings before a consumer arbitration board." + } + } + }, + "privacy": { + "title": "Privacy policy", + "description": "Information on processing personal data.", + "sections": { + "controller": { + "title": "Controller", + "body": "The operator of this self-hosted instance is responsible for data processing. Adapt this policy to your organization." + }, + "data": { + "title": "Data collected", + "body": "We process account data (email, username), session information, server metadata, and usage data for billing." + }, + "cookies": { + "title": "Cookies", + "body": "Session cookies (hgc_session) and CSRF cookies (hgc_csrf) are required for authentication." + }, + "rights": { + "title": "Your rights", + "body": "You have the right to access, rectify, delete, and port your data under applicable privacy law." + }, + "contact": { + "title": "Contact", + "body": "For privacy requests, contact the operator listed in your legal notice." + } + } + }, + "terms": { + "title": "Terms of service", + "description": "Terms of use for the platform.", + "sections": { + "scope": { + "title": "Scope", + "body": "These terms apply to use of the Minecraft server hosting platform by registered users." + }, + "usage": { + "title": "Usage", + "body": "Servers may only be operated for lawful purposes. Abuse may result in suspension." + }, + "billing": { + "title": "Billing", + "body": "Credits are consumed based on runtime. Balance is non-transferable unless otherwise agreed." + }, + "liability": { + "title": "Liability", + "body": "The operator is liable only for intent and gross negligence. Protect against data loss with your own backups." + }, + "changes": { + "title": "Changes", + "body": "The operator may change these terms with reasonable notice. Continued use constitutes acceptance." + } + } } } } diff --git a/apps/web/src/app/[locale]/(account)/billing/page.tsx b/apps/web/src/app/[locale]/(account)/billing/page.tsx new file mode 100644 index 0000000..d6e1a9c --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/billing/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { BillingContent } from "@/components/account/billing-content"; + +interface BillingPageProps { + params: Promise<{ locale: string }>; +} + +export default async function BillingPage({ params }: BillingPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/(account)/layout.tsx b/apps/web/src/app/[locale]/(account)/layout.tsx new file mode 100644 index 0000000..0f9b576 --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/layout.tsx @@ -0,0 +1,24 @@ +import { setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; +import { AccountNav } from "@/components/layout/account-nav"; +import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; + +interface AccountLayoutProps { + children: ReactNode; + params: Promise<{ locale: string }>; +} + +export default async function AccountLayout({ children, params }: AccountLayoutProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ( +
+
+ + +
+ {children} +
+ ); +} diff --git a/apps/web/src/app/[locale]/(account)/notifications/page.tsx b/apps/web/src/app/[locale]/(account)/notifications/page.tsx new file mode 100644 index 0000000..b7bf5cf --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/notifications/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { NotificationsContent } from "@/components/account/notifications-content"; + +interface NotificationsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function NotificationsPage({ params }: NotificationsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/(account)/profile/page.tsx b/apps/web/src/app/[locale]/(account)/profile/page.tsx new file mode 100644 index 0000000..7ba5f0c --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/profile/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ProfileContent } from "@/components/account/profile-content"; + +interface ProfilePageProps { + params: Promise<{ locale: string }>; +} + +export default async function ProfilePage({ params }: ProfilePageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/audit/page.tsx b/apps/web/src/app/[locale]/admin/audit/page.tsx new file mode 100644 index 0000000..a816d30 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/audit/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminAuditContent } from "@/components/admin/admin-audit-content"; + +interface AdminAuditPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminAuditPage({ params }: AdminAuditPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/jobs/page.tsx b/apps/web/src/app/[locale]/admin/jobs/page.tsx new file mode 100644 index 0000000..64d40b8 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/jobs/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminJobsContent } from "@/components/admin/admin-jobs-content"; + +interface AdminJobsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminJobsPage({ params }: AdminJobsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/layout.tsx b/apps/web/src/app/[locale]/admin/layout.tsx new file mode 100644 index 0000000..2aae170 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/layout.tsx @@ -0,0 +1,34 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; +import { AdminNav } from "@/components/layout/admin-nav"; +import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; +import { Link } from "@/i18n/navigation"; + +interface AdminLayoutProps { + children: ReactNode; + params: Promise<{ locale: string }>; +} + +export default async function AdminLayout({ children, params }: AdminLayoutProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("admin"); + + return ( +
+
+ + {t("backToPanel")} + +
+
+ + +
+ {children} +
+ ); +} diff --git a/apps/web/src/app/[locale]/admin/nodes/page.tsx b/apps/web/src/app/[locale]/admin/nodes/page.tsx new file mode 100644 index 0000000..1cd8fd3 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/nodes/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminNodesContent } from "@/components/admin/admin-nodes-content"; + +interface AdminNodesPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminNodesPage({ params }: AdminNodesPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/page.tsx b/apps/web/src/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..c671bc1 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminDashboardContent } from "@/components/admin/admin-dashboard-content"; + +interface AdminPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminPage({ params }: AdminPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/users/page.tsx b/apps/web/src/app/[locale]/admin/users/page.tsx new file mode 100644 index 0000000..9a7396d --- /dev/null +++ b/apps/web/src/app/[locale]/admin/users/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminUsersContent } from "@/components/admin/admin-users-content"; + +interface AdminUsersPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminUsersPage({ params }: AdminUsersPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/dashboard/layout.tsx b/apps/web/src/app/[locale]/dashboard/layout.tsx index 424f701..0b394cf 100644 --- a/apps/web/src/app/[locale]/dashboard/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/layout.tsx @@ -1,6 +1,6 @@ -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { setRequestLocale } from "next-intl/server"; import type { ReactNode } from "react"; -import { Link } from "@/i18n/navigation"; +import { AccountNav } from "@/components/layout/account-nav"; import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; interface DashboardLayoutProps { @@ -14,32 +14,11 @@ export default async function DashboardLayout({ }: DashboardLayoutProps) { const { locale } = await params; setRequestLocale(locale); - const t = await getTranslations("common"); return (
- +
{children} diff --git a/apps/web/src/app/[locale]/docs/page.tsx b/apps/web/src/app/[locale]/docs/page.tsx new file mode 100644 index 0000000..cc095e7 --- /dev/null +++ b/apps/web/src/app/[locale]/docs/page.tsx @@ -0,0 +1,48 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; +import { Link } from "@/i18n/navigation"; +import { getApiUrl } from "@/lib/env"; + +interface DocsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function DocsPage({ params }: DocsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.docs"); + const apiUrl = getApiUrl(); + + const sections = ["gettingStarted", "api", "operations", "whmcs"] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +

+ {t("openapi")}{" "} + + {t("openapiLink")} + +

+

+ + {t("cta")} + +

+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/features/page.tsx b/apps/web/src/app/[locale]/features/page.tsx new file mode 100644 index 0000000..2efc263 --- /dev/null +++ b/apps/web/src/app/[locale]/features/page.tsx @@ -0,0 +1,30 @@ +import { Card, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface FeaturesPageProps { + params: Promise<{ locale: string }>; +} + +export default async function FeaturesPage({ params }: FeaturesPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.features"); + + const items = ["control", "nodes", "security", "billing", "backups", "whmcs"] as const; + + return ( + +
+ {items.map((item) => ( + + + {t(`items.${item}.title`)} + {t(`items.${item}.description`)} + + + ))} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/impressum/page.tsx b/apps/web/src/app/[locale]/impressum/page.tsx new file mode 100644 index 0000000..f100ea7 --- /dev/null +++ b/apps/web/src/app/[locale]/impressum/page.tsx @@ -0,0 +1,37 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface ImpressumPageProps { + params: Promise<{ locale: string }>; +} + +export default async function ImpressumPage({ params }: ImpressumPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.impressum"); + + const sections = [ + "operator", + "address", + "contact", + "responsible", + "register", + "dispute", + ] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +

{t("notice")}

+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/pricing/page.tsx b/apps/web/src/app/[locale]/pricing/page.tsx new file mode 100644 index 0000000..2d2237c --- /dev/null +++ b/apps/web/src/app/[locale]/pricing/page.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; +import { Link } from "@/i18n/navigation"; + +interface PricingPageProps { + params: Promise<{ locale: string }>; +} + +export default async function PricingPage({ params }: PricingPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.pricing"); + + const tiers = ["starter", "standard", "pro"] as const; + + return ( + +
+ {tiers.map((tier) => ( + + + {t(`tiers.${tier}.name`)} + {t(`tiers.${tier}.description`)} + + +

+ {t(`tiers.${tier}.price`)} +

+
    + {(t.raw(`tiers.${tier}.features`) as string[]).map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+
+ ))} +
+

+ + {t("cta")} + +

+
+ ); +} diff --git a/apps/web/src/app/[locale]/privacy/page.tsx b/apps/web/src/app/[locale]/privacy/page.tsx new file mode 100644 index 0000000..9cb6e7d --- /dev/null +++ b/apps/web/src/app/[locale]/privacy/page.tsx @@ -0,0 +1,29 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface PrivacyPageProps { + params: Promise<{ locale: string }>; +} + +export default async function PrivacyPage({ params }: PrivacyPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.privacy"); + + const sections = ["controller", "data", "cookies", "rights", "contact"] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/security/page.tsx b/apps/web/src/app/[locale]/security/page.tsx index 68ffb79..327cdec 100644 --- a/apps/web/src/app/[locale]/security/page.tsx +++ b/apps/web/src/app/[locale]/security/page.tsx @@ -1,5 +1,7 @@ import { setRequestLocale } from "next-intl/server"; import { SecurityContent } from "@/components/auth/security-content"; +import { AccountNav } from "@/components/layout/account-nav"; +import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; interface SecurityPageProps { params: Promise<{ locale: string }>; @@ -11,6 +13,10 @@ export default async function SecurityPage({ params }: SecurityPageProps) { return (
+
+ + +
); diff --git a/apps/web/src/app/[locale]/servers/[id]/access/page.tsx b/apps/web/src/app/[locale]/servers/[id]/access/page.tsx new file mode 100644 index 0000000..87ca0e9 --- /dev/null +++ b/apps/web/src/app/[locale]/servers/[id]/access/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ServerAccess } from "@/components/servers/server-access"; + +interface ServerAccessPageProps { + params: Promise<{ locale: string; id: string }>; +} + +export default async function ServerAccessPage({ params }: ServerAccessPageProps) { + const { locale, id } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx b/apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx new file mode 100644 index 0000000..58bc18f --- /dev/null +++ b/apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ServerSchedules } from "@/components/servers/server-schedules"; + +interface ServerSchedulesPageProps { + params: Promise<{ locale: string; id: string }>; +} + +export default async function ServerSchedulesPage({ params }: ServerSchedulesPageProps) { + const { locale, id } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/servers/[id]/software/page.tsx b/apps/web/src/app/[locale]/servers/[id]/software/page.tsx new file mode 100644 index 0000000..5889cd1 --- /dev/null +++ b/apps/web/src/app/[locale]/servers/[id]/software/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ServerSoftware } from "@/components/servers/server-software"; + +interface ServerSoftwarePageProps { + params: Promise<{ locale: string; id: string }>; +} + +export default async function ServerSoftwarePage({ params }: ServerSoftwarePageProps) { + const { locale, id } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/servers/layout.tsx b/apps/web/src/app/[locale]/servers/layout.tsx index 4795dd0..4451362 100644 --- a/apps/web/src/app/[locale]/servers/layout.tsx +++ b/apps/web/src/app/[locale]/servers/layout.tsx @@ -1,6 +1,6 @@ -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { setRequestLocale } from "next-intl/server"; import type { ReactNode } from "react"; -import { Link } from "@/i18n/navigation"; +import { AccountNav } from "@/components/layout/account-nav"; import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; interface ServersLayoutProps { @@ -11,32 +11,11 @@ interface ServersLayoutProps { export default async function ServersLayout({ children, params }: ServersLayoutProps) { const { locale } = await params; setRequestLocale(locale); - const t = await getTranslations("common"); return (
- +
{children} diff --git a/apps/web/src/app/[locale]/status/page.tsx b/apps/web/src/app/[locale]/status/page.tsx new file mode 100644 index 0000000..a8d25c6 --- /dev/null +++ b/apps/web/src/app/[locale]/status/page.tsx @@ -0,0 +1,38 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface StatusPageProps { + params: Promise<{ locale: string }>; +} + +export default async function StatusPage({ params }: StatusPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.status"); + + const components = ["api", "worker", "panel", "dns"] as const; + + return ( + +
+ {components.map((component) => ( + + +
+ {t(`components.${component}.name`)} + {t(`components.${component}.description`)} +
+ + {t("operational")} + +
+ +

{t("selfHostedNote")}

+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/terms/page.tsx b/apps/web/src/app/[locale]/terms/page.tsx new file mode 100644 index 0000000..aff0f59 --- /dev/null +++ b/apps/web/src/app/[locale]/terms/page.tsx @@ -0,0 +1,29 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface TermsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function TermsPage({ params }: TermsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.terms"); + + const sections = ["scope", "usage", "billing", "liability", "changes"] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/account/billing-content.tsx b/apps/web/src/components/account/billing-content.tsx new file mode 100644 index 0000000..0891c54 --- /dev/null +++ b/apps/web/src/components/account/billing-content.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { getWallet } from "@/lib/api/billing"; + +export function BillingContent() { + const t = useTranslations("account.billing"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["wallet"], + queryFn: getWallet, + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError || !data) { + return

{t("loadError")}

; + } + + const transactions = data.transactions ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + + + {t("balanceTitle")} + + {t("periodStart", { + date: new Date(data.periodStart).toLocaleDateString(), + })} + + + +

+ {data.balance} +

+

{t("creditsLabel")}

+
+
+ +
+

+ {t("transactionsTitle")} +

+ {transactions.length === 0 ? ( +

{t("transactionsEmpty")}

+ ) : ( +
+ + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
{t("columns.date")}{t("columns.type")}{t("columns.amount")}{t("columns.reference")}
+ {new Date(tx.createdAt).toLocaleString()} + {tx.type}{tx.amount} + {tx.referenceType ?? "—"} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/account/notifications-content.tsx b/apps/web/src/components/account/notifications-content.tsx new file mode 100644 index 0000000..75df98b --- /dev/null +++ b/apps/web/src/components/account/notifications-content.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + getNotificationPreferences, + listNotifications, + markAllNotificationsRead, + markNotificationRead, + updateNotificationPreferences, + type Notification, + type NotificationPreference, +} from "@/lib/api/notifications"; + +function formatDate(value: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export function NotificationsContent() { + const t = useTranslations("account.notifications"); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["notifications"], + queryFn: listNotifications, + }); + + const { data: preferencesData } = useQuery({ + queryKey: ["notification-preferences"], + queryFn: getNotificationPreferences, + }); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ["notifications"] }); + void queryClient.invalidateQueries({ queryKey: ["notification-preferences"] }); + }; + + const markReadMutation = useMutation({ + mutationFn: markNotificationRead, + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.markReadFailed")); + }, + }); + + const markAllReadMutation = useMutation({ + mutationFn: markAllNotificationsRead, + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.markReadFailed")); + }, + }); + + const preferencesMutation = useMutation({ + mutationFn: (preferences: NotificationPreference[]) => + updateNotificationPreferences(preferences), + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError( + err instanceof ApiError ? (err.detail ?? err.title) : t("errors.preferencesFailed"), + ); + }, + }); + + function togglePreference(pref: NotificationPreference) { + const current = preferencesData?.preferences ?? []; + const next = current.some( + (p) => p.type === pref.type && p.channel === pref.channel, + ) + ? current.map((p) => + p.type === pref.type && p.channel === pref.channel + ? { ...p, enabled: !p.enabled } + : p, + ) + : [...current, { ...pref, enabled: false }]; + preferencesMutation.mutate(next); + } + + const notifications = data?.notifications ?? []; + const unreadCount = data?.unreadCount ?? 0; + + return ( +
+
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ {unreadCount > 0 ? ( + + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + + {t("listTitle")} + + {unreadCount > 0 ? t("unreadCount", { count: unreadCount }) : t("allRead")} + + + + {isLoading ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("errors.loadFailed")}

+ ) : notifications.length === 0 ? ( +
+

{t("emptyTitle")}

+

+ {t("emptyDescription")} +

+
+ ) : ( +
    + {notifications.map((notification: Notification) => ( +
  • +
    +

    + {notification.title} +

    +

    + {notification.body} +

    +

    + {formatDate(notification.createdAt)} · {t(`types.${notification.type}`)} +

    +
    + {!notification.readAt ? ( + + ) : null} +
  • + ))} +
+ )} +
+
+ + {(preferencesData?.preferences ?? []).length > 0 ? ( + + + {t("preferencesTitle")} + {t("preferencesDescription")} + + +
    + {preferencesData!.preferences.map((pref) => ( +
  • +
    +

    + {t(`types.${pref.type}`)} +

    +

    {t(`channels.${pref.channel}`)}

    +
    + +
  • + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/account/profile-content.tsx b/apps/web/src/components/account/profile-content.tsx new file mode 100644 index 0000000..7953fd5 --- /dev/null +++ b/apps/web/src/components/account/profile-content.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; +import { useAuth } from "@/components/providers/auth-provider"; + +export function ProfileContent() { + const t = useTranslations("account.profile"); + const { user, isLoading } = useAuth(); + + if (isLoading) { + return

{t("loading")}

; + } + + if (!user) { + return

{t("notSignedIn")}

; + } + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + + + {t("accountInfo")} + {t("accountInfoDescription")} + + +
+ {t("fields.email")} + {user.email} +
+
+ {t("fields.username")} + {user.username} +
+ {user.displayName ? ( +
+ {t("fields.displayName")} + {user.displayName} +
+ ) : null} +
+ {t("fields.emailVerified")} + + {user.emailVerified || user.emailVerifiedAt ? t("yes") : t("no")} + +
+
+ {t("fields.twoFactor")} + + {user.twoFactorEnabled ? t("enabled") : t("disabled")} + +
+
+
+ +

+ {t("securityHint")}{" "} + + {t("securityLink")} + +

+
+ ); +} diff --git a/apps/web/src/components/admin/admin-audit-content.tsx b/apps/web/src/components/admin/admin-audit-content.tsx new file mode 100644 index 0000000..6346933 --- /dev/null +++ b/apps/web/src/components/admin/admin-audit-content.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Button } from "@hexahost/ui"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { listAdminAuditEvents } from "@/lib/api/admin"; + +export function AdminAuditContent() { + const t = useTranslations("admin.audit"); + const [cursor, setCursor] = useState(undefined); + const [allItems, setAllItems] = useState< + Awaited>["items"] + >([]); + + const { data, isLoading, isError, isFetching } = useQuery({ + queryKey: ["admin", "audit", cursor], + queryFn: async () => { + const result = await listAdminAuditEvents({ limit: 50, cursor }); + if (!cursor) { + setAllItems(result.items); + } else { + setAllItems((prev) => [...prev, ...result.items]); + } + return result; + }, + }); + + const items = cursor ? allItems : (data?.items ?? []); + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {isLoading && items.length === 0 ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("loadError")}

+ ) : items.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + {items.map((event) => ( + + + + + + + ))} + +
{t("columns.time")}{t("columns.action")}{t("columns.user")}{t("columns.entity")}
+ {new Date(event.createdAt).toLocaleString()} + {event.action} + {event.user?.email ?? "—"} + + {event.entityType ?? "—"} + {event.entityId ? ` · ${event.entityId.slice(0, 8)}…` : ""} +
+
+ )} + + {data?.nextCursor ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/admin/admin-dashboard-content.tsx b/apps/web/src/components/admin/admin-dashboard-content.tsx new file mode 100644 index 0000000..d786c20 --- /dev/null +++ b/apps/web/src/components/admin/admin-dashboard-content.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Card, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { getAdminDashboardStats } from "@/lib/api/admin"; + +export function AdminDashboardContent() { + const t = useTranslations("admin.dashboard"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "dashboard"], + queryFn: getAdminDashboardStats, + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError || !data) { + return ( +

{t("loadError")}

+ ); + } + + const stats = [ + { key: "activeUsers", value: data.activeUsers }, + { key: "totalServers", value: data.totalServers }, + { key: "runningServers", value: data.runningServers }, + { key: "queueLength", value: data.queueLength }, + { key: "onlineNodes", value: data.onlineNodes }, + { key: "failedJobs24h", value: data.failedJobs24h }, + { key: "openAbuseReports", value: data.openAbuseReports }, + { key: "openTickets", value: data.openTickets }, + ] as const; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
+ {stats.map((stat) => ( + + + {t(`stats.${stat.key}`)} + {stat.value} + + + ))} +
+ +

+ {t("generatedAt", { time: new Date(data.generatedAt).toLocaleString() })} +

+
+ ); +} diff --git a/apps/web/src/components/admin/admin-jobs-content.tsx b/apps/web/src/components/admin/admin-jobs-content.tsx new file mode 100644 index 0000000..bcb1a52 --- /dev/null +++ b/apps/web/src/components/admin/admin-jobs-content.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { listAdminJobs } from "@/lib/api/admin"; + +export function AdminJobsContent() { + const t = useTranslations("admin.jobs"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "jobs"], + queryFn: () => listAdminJobs({ limit: 50 }), + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError) { + return

{t("loadError")}

; + } + + const jobs = data?.jobs ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {jobs.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + ))} + +
{t("columns.queue")}{t("columns.job")}{t("columns.status")}{t("columns.attempts")}{t("columns.created")}
{job.queue}{job.jobName}{job.status}{job.attempts} + {new Date(job.createdAt).toLocaleString()} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin/admin-nodes-content.tsx b/apps/web/src/components/admin/admin-nodes-content.tsx new file mode 100644 index 0000000..79ac618 --- /dev/null +++ b/apps/web/src/components/admin/admin-nodes-content.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { Button } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { + drainAdminNode, + listAdminNodes, + setAdminNodeMaintenance, +} from "@/lib/api/admin"; +import { ApiError } from "@/lib/api/auth"; + +export function AdminNodesContent() { + const t = useTranslations("admin.nodes"); + const queryClient = useQueryClient(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "nodes"], + queryFn: listAdminNodes, + }); + + const drainMutation = useMutation({ + mutationFn: drainAdminNode, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "nodes"] }), + }); + + const maintenanceMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + setAdminNodeMaintenance(id, enabled), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "nodes"] }), + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError) { + return

{t("loadError")}

; + } + + const nodes = data?.nodes ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {nodes.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + ))} + +
{t("columns.name")}{t("columns.status")}{t("columns.servers")}{t("columns.ram")}{t("columns.actions")}
+

{node.name}

+

{node.hostname}

+
{node.status} + {node.activeServers} / {node.maxServers} + + {node.availableRamMb} / {node.maxRamMb} MB + +
+ + +
+
+
+ )} + + {(drainMutation.isError || maintenanceMutation.isError) && ( +

+ {drainMutation.error instanceof ApiError + ? (drainMutation.error.detail ?? drainMutation.error.title) + : maintenanceMutation.error instanceof ApiError + ? (maintenanceMutation.error.detail ?? maintenanceMutation.error.title) + : t("actionError")} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/admin/admin-users-content.tsx b/apps/web/src/components/admin/admin-users-content.tsx new file mode 100644 index 0000000..4684c52 --- /dev/null +++ b/apps/web/src/components/admin/admin-users-content.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { searchAdminUsers, suspendAdminUser, unsuspendAdminUser } from "@/lib/api/admin"; +import { ApiError } from "@/lib/api/auth"; + +export function AdminUsersContent() { + const t = useTranslations("admin.users"); + const queryClient = useQueryClient(); + const [query, setQuery] = useState(""); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["admin", "users", query], + queryFn: () => searchAdminUsers({ q: query || undefined, limit: 50 }), + }); + + const suspendMutation = useMutation({ + mutationFn: suspendAdminUser, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }), + }); + + const unsuspendMutation = useMutation({ + mutationFn: unsuspendAdminUser, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }), + }); + + const users = data?.users ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
{ + event.preventDefault(); + void refetch(); + }} + > + setQuery(event.target.value)} + placeholder={t("searchPlaceholder")} + className="flex h-10 flex-1 rounded-md border border-zinc-300 bg-white px-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" + /> + +
+ + {isLoading ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("loadError")}

+ ) : users.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
{t("columns.user")}{t("columns.status")}{t("columns.roles")}{t("columns.servers")}{t("columns.actions")}
+

+ {user.displayName ?? user.username} +

+

{user.email}

+
{user.status}{user.roles.join(", ")}{user.serverCount} + {user.status === "SUSPENDED" ? ( + + ) : ( + + )} +
+
+ )} + + {(suspendMutation.isError || unsuspendMutation.isError) && ( +

+ {suspendMutation.error instanceof ApiError + ? (suspendMutation.error.detail ?? suspendMutation.error.title) + : unsuspendMutation.error instanceof ApiError + ? (unsuspendMutation.error.detail ?? unsuspendMutation.error.title) + : t("actionError")} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/dashboard-user-info.tsx b/apps/web/src/components/dashboard/dashboard-user-info.tsx index f541df3..b20b656 100644 --- a/apps/web/src/components/dashboard/dashboard-user-info.tsx +++ b/apps/web/src/components/dashboard/dashboard-user-info.tsx @@ -33,6 +33,14 @@ export function DashboardUserInfo() {

{user.email}

+ {user.roles?.includes("SUPER_ADMIN") ? ( + + {t("admin")} + + ) : null} +
    + {links.map((link) => { + const active = + pathname === link.href || pathname.startsWith(`${link.href}/`); + + return ( +
  • + + {t(link.key)} + +
  • + ); + })} +
+ + ); +} diff --git a/apps/web/src/components/layout/admin-nav.tsx b/apps/web/src/components/layout/admin-nav.tsx new file mode 100644 index 0000000..1be36f9 --- /dev/null +++ b/apps/web/src/components/layout/admin-nav.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Link, usePathname } from "@/i18n/navigation"; + +const links = [ + { key: "dashboard", href: "/admin" }, + { key: "nodes", href: "/admin/nodes" }, + { key: "users", href: "/admin/users" }, + { key: "jobs", href: "/admin/jobs" }, + { key: "audit", href: "/admin/audit" }, +] as const; + +export function AdminNav() { + const t = useTranslations("admin.nav"); + const pathname = usePathname(); + + function isActive(href: string): boolean { + if (href === "/admin") { + return pathname === "/admin"; + } + return pathname === href || pathname.startsWith(`${href}/`); + } + + return ( + + ); +} diff --git a/apps/web/src/components/layout/footer.tsx b/apps/web/src/components/layout/footer.tsx index c336c9d..885d6d9 100644 --- a/apps/web/src/components/layout/footer.tsx +++ b/apps/web/src/components/layout/footer.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; import { getAppName } from "@/lib/env"; export function Footer() { @@ -10,13 +11,27 @@ export function Footer() { return (
-
-

- © {year} {appName}. {t("footer")} -

-

- Phase 0 · Frontend Foundation -

+
+ +
+

+ © {year} {appName}. {t("footer")} +

+

{t("phaseBadge")}

+
); diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 44d7b5b..e9f7408 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -24,6 +24,24 @@ export function Header() { className="flex items-center gap-2 sm:gap-4" aria-label="Main navigation" > + + {t("features")} + + + {t("pricing")} + + + {t("docs")} + +
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/providers/auth-provider.tsx b/apps/web/src/components/providers/auth-provider.tsx index f641e03..72b5bd1 100644 --- a/apps/web/src/components/providers/auth-provider.tsx +++ b/apps/web/src/components/providers/auth-provider.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; import { + clearCsrfToken, getCurrentUser, submitLogout, type CurrentUser, @@ -54,6 +55,7 @@ export function AuthProvider({ children }: AuthProviderProps) { try { await submitLogout(); } finally { + clearCsrfToken(); setUser(null); router.push("/login"); } diff --git a/apps/web/src/components/servers/create-server-form.tsx b/apps/web/src/components/servers/create-server-form.tsx index bebad50..c1454bb 100644 --- a/apps/web/src/components/servers/create-server-form.tsx +++ b/apps/web/src/components/servers/create-server-form.tsx @@ -1,212 +1 @@ -"use client"; - -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { Link, useRouter } from "@/i18n/navigation"; -import { ApiError } from "@/lib/api/auth"; -import { createServer, listPlans } from "@/lib/api/servers"; -import { - createServerSchema, - MINECRAFT_VERSIONS, - SOFTWARE_FAMILIES, - type CreateServerFormValues, -} from "@/lib/schemas/server"; - -const inputClassName = - "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; - -export function CreateServerForm() { - const t = useTranslations("servers"); - const tv = useTranslations("validation"); - const router = useRouter(); - const [apiError, setApiError] = useState(null); - - const { data: plansData, isLoading: plansLoading } = useQuery({ - queryKey: ["plans"], - queryFn: listPlans, - retry: false, - }); - - const schema = createServerSchema({ - nameRequired: tv("serverNameRequired"), - nameMax: tv("serverNameMax"), - planRequired: tv("planRequired"), - planInvalid: tv("planInvalid"), - versionRequired: tv("versionRequired"), - softwareRequired: tv("softwareRequired"), - eulaRequired: tv("eulaRequired"), - }); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - name: "", - planId: "", - minecraftVersion: MINECRAFT_VERSIONS[0], - softwareFamily: "VANILLA", - eulaAccepted: false, - }, - }); - - const createMutation = useMutation({ - mutationFn: createServer, - onSuccess: (server) => { - router.push(`/servers/${server.id}`); - }, - onError: (error: unknown) => { - setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic")); - }, - }); - - async function onSubmit(values: CreateServerFormValues) { - setApiError(null); - await createMutation.mutateAsync({ - ...values, - eulaAccepted: true, - }); - } - - const plans = plansData?.plans ?? []; - - return ( - - - {t("createTitle")} - {t("createDescription")} - - -
-
- - - {errors.name ? ( -

{errors.name.message}

- ) : null} -
- -
- - - {errors.planId ? ( -

{errors.planId.message}

- ) : null} - {!plansLoading && plans.length === 0 ? ( -

{t("noPlans")}

- ) : null} -
- -
- - - {errors.softwareFamily ? ( -

- {errors.softwareFamily.message} -

- ) : null} -
- -
- - - {errors.minecraftVersion ? ( -

- {errors.minecraftVersion.message} -

- ) : null} -
- -
- - {errors.eulaAccepted ? ( -

{errors.eulaAccepted.message}

- ) : null} -
- - {apiError ? ( -

- {apiError} -

- ) : null} - -
- - - {t("cancel")} - -
-
-
-
- ); -} +export { CreateServerWizard as CreateServerForm } from "./create-server-wizard"; diff --git a/apps/web/src/components/servers/create-server-wizard.tsx b/apps/web/src/components/servers/create-server-wizard.tsx new file mode 100644 index 0000000..1bf0ccf --- /dev/null +++ b/apps/web/src/components/servers/create-server-wizard.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Link, useRouter } from "@/i18n/navigation"; +import { ApiError } from "@/lib/api/auth"; +import { createServer, listPlans } from "@/lib/api/servers"; +import { + createServerSchema, + MINECRAFT_VERSIONS, + SOFTWARE_FAMILIES, + type CreateServerFormValues, +} from "@/lib/schemas/server"; + +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const STEPS = ["basics", "software", "review"] as const; +type WizardStep = (typeof STEPS)[number]; + +export function CreateServerWizard() { + const t = useTranslations("servers"); + const tw = useTranslations("servers.wizard"); + const tv = useTranslations("validation"); + const router = useRouter(); + const [step, setStep] = useState("basics"); + const [apiError, setApiError] = useState(null); + + const { data: plansData, isLoading: plansLoading } = useQuery({ + queryKey: ["plans"], + queryFn: listPlans, + retry: false, + }); + + const schema = createServerSchema({ + nameRequired: tv("serverNameRequired"), + nameMax: tv("serverNameMax"), + planRequired: tv("planRequired"), + planInvalid: tv("planInvalid"), + versionRequired: tv("versionRequired"), + softwareRequired: tv("softwareRequired"), + eulaRequired: tv("eulaRequired"), + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + planId: "", + minecraftVersion: MINECRAFT_VERSIONS[0], + softwareFamily: "VANILLA", + eulaAccepted: false, + }, + mode: "onChange", + }); + + const { + register, + handleSubmit, + trigger, + watch, + formState: { errors, isSubmitting }, + } = form; + + const createMutation = useMutation({ + mutationFn: createServer, + onSuccess: (server) => { + router.push(`/servers/${server.id}`); + }, + onError: (error: unknown) => { + setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic")); + }, + }); + + const plans = plansData?.plans ?? []; + const values = watch(); + const stepIndex = STEPS.indexOf(step); + + async function goNext() { + setApiError(null); + if (step === "basics") { + const valid = await trigger(["name", "planId"]); + if (valid) setStep("software"); + return; + } + if (step === "software") { + const valid = await trigger(["softwareFamily", "minecraftVersion"]); + if (valid) setStep("review"); + } + } + + function goBack() { + setApiError(null); + if (step === "software") setStep("basics"); + if (step === "review") setStep("software"); + } + + async function onSubmit(formValues: CreateServerFormValues) { + setApiError(null); + await createMutation.mutateAsync({ + ...formValues, + eulaAccepted: true, + }); + } + + const selectedPlan = plans.find((plan) => plan.id === values.planId); + + return ( + + + {t("createTitle")} + {t("createDescription")} +
    + {STEPS.map((stepKey, index) => ( +
  1. + {tw(`steps.${stepKey}`)} +
  2. + ))} +
+
+ +
+ {step === "basics" ? ( + <> +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : null} +
+
+ + + {errors.planId ? ( +

{errors.planId.message}

+ ) : null} + {!plansLoading && plans.length === 0 ? ( +

{t("noPlans")}

+ ) : null} +
+ + ) : null} + + {step === "software" ? ( + <> +
+ + + {errors.softwareFamily ? ( +

+ {errors.softwareFamily.message} +

+ ) : null} +
+
+ + + {errors.minecraftVersion ? ( +

+ {errors.minecraftVersion.message} +

+ ) : null} +
+ + ) : null} + + {step === "review" ? ( + <> +
+
+
{t("name")}
+
{values.name}
+
+
+
{t("plan")}
+
+ {selectedPlan?.name ?? "—"} +
+
+
+
{t("software")}
+
+ {t(`softwareOptions.${values.softwareFamily}`)} +
+
+
+
{t("version")}
+
+ {values.minecraftVersion} +
+
+
+
+ + {errors.eulaAccepted ? ( +

+ {errors.eulaAccepted.message} +

+ ) : null} +
+ + ) : null} + + {apiError ? ( +

+ {apiError} +

+ ) : null} + +
+ {step !== "basics" ? ( + + ) : null} + {step !== "review" ? ( + + ) : ( + + )} + + {t("cancel")} + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/servers/server-access.tsx b/apps/web/src/components/servers/server-access.tsx new file mode 100644 index 0000000..17787b1 --- /dev/null +++ b/apps/web/src/components/servers/server-access.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + inviteMember, + listMembers, + removeMember, + type MemberRole, + type ServerMember, +} from "@/lib/api/members"; + +interface ServerAccessProps { + serverId: string; +} + +const INVITE_ROLES: MemberRole[] = ["ADMIN", "OPERATOR", "DEVELOPER", "VIEWER"]; + +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300"; + +function memberDisplayName(member: ServerMember): string { + return member.displayName ?? member.username; +} + +export function ServerAccess({ serverId }: ServerAccessProps) { + const t = useTranslations("servers.access"); + const queryClient = useQueryClient(); + const [showInviteForm, setShowInviteForm] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("VIEWER"); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["server-members", serverId], + queryFn: () => listMembers(serverId), + }); + + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: ["server-members", serverId] }); + + const inviteMutation = useMutation({ + mutationFn: () => inviteMember(serverId, { email: email.trim(), role }), + onSuccess: (response) => { + setError(null); + if (response.status === "pending") { + setInfo(t("invitePending")); + } else { + setInfo(t("inviteSuccess")); + setShowInviteForm(false); + setEmail(""); + setRole("VIEWER"); + invalidate(); + } + }, + onError: (err: unknown) => { + setInfo(null); + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.inviteFailed")); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (memberId: string) => removeMember(serverId, memberId), + onSuccess: () => { + setError(null); + setInfo(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.removeFailed")); + }, + }); + + function startInvite() { + setShowInviteForm(true); + setError(null); + setInfo(null); + } + + function cancelInvite() { + setShowInviteForm(false); + setEmail(""); + setRole("VIEWER"); + setError(null); + setInfo(null); + } + + function handleRemove(member: ServerMember) { + const name = memberDisplayName(member); + if (!window.confirm(t("removeConfirm", { name }))) { + return; + } + removeMutation.mutate(member.id); + } + + const members = data?.members ?? []; + + return ( + + +
+
+ {t("title")} + {t("description")} +
+ {!showInviteForm ? ( + + ) : null} +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + {info ? ( +

+ {info} +

+ ) : null} + + {showInviteForm ? ( +
+
+
+ + setEmail(e.target.value)} + placeholder={t("fields.emailPlaceholder")} + /> +
+
+ + +
+
+
+ + +
+
+ ) : null} + + {isLoading ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("errors.loadFailed")}

+ ) : members.length === 0 && !showInviteForm ? ( +

{t("empty")}

+ ) : members.length > 0 ? ( +
+ + + + + + + + + + + + {members.map((member) => ( + + + + + + + + ))} + +
+ {t("columns.member")} + + {t("columns.email")} + + {t("columns.role")} + + {t("columns.status")} + + {t("columns.actions")} +
+ {memberDisplayName(member)} + {member.email} + {t(`roles.${member.role}`)} + + + {member.acceptedAt ? t("status.active") : t("status.pending")} + + +
+ +
+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/servers/server-files.tsx b/apps/web/src/components/servers/server-files.tsx index 3bd8d86..1645d05 100644 --- a/apps/web/src/components/servers/server-files.tsx +++ b/apps/web/src/components/servers/server-files.tsx @@ -3,9 +3,17 @@ import { Button } from "@hexahost/ui"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ApiError } from "@/lib/api/auth"; -import { listFiles, readFile, writeFile } from "@/lib/api/files"; +import { + archiveFiles, + deleteFile, + listFiles, + readFile, + unarchiveFile, + uploadFile, + writeFile, +} from "@/lib/api/files"; import { getServer } from "@/lib/api/servers"; interface ServerFilesProps { @@ -15,14 +23,35 @@ interface ServerFilesProps { const textareaClassName = "min-h-80 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-sm text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; +function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(",")[1]; + if (!base64) { + reject(new Error("Failed to encode file")); + return; + } + resolve(base64); + }; + reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} + export function ServerFiles({ serverId }: ServerFilesProps) { const t = useTranslations("servers.files"); const queryClient = useQueryClient(); + const fileInputRef = useRef(null); const [currentPath, setCurrentPath] = useState(""); const [selectedFile, setSelectedFile] = useState(null); + const [selectedPaths, setSelectedPaths] = useState([]); const [editorContent, setEditorContent] = useState(""); const [fileVersion, setFileVersion] = useState(); const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); const [saveSuccess, setSaveSuccess] = useState(false); const { data: server } = useQuery({ @@ -32,6 +61,10 @@ export function ServerFiles({ serverId }: ServerFilesProps) { const canAccessFiles = server?.status === "RUNNING" || server?.status === "STOPPED"; + const isReadOnly = server?.status === "STOPPED"; + + const invalidateListing = () => + void queryClient.invalidateQueries({ queryKey: ["server-files", serverId, currentPath] }); const { data: listing, @@ -60,6 +93,10 @@ export function ServerFiles({ serverId }: ServerFilesProps) { } }, [fileData]); + useEffect(() => { + setSelectedPaths([]); + }, [currentPath]); + const saveMutation = useMutation({ mutationFn: () => writeFile(serverId, { @@ -80,6 +117,75 @@ export function ServerFiles({ serverId }: ServerFilesProps) { }, }); + const deleteMutation = useMutation({ + mutationFn: (path: string) => deleteFile(serverId, path), + onSuccess: () => { + setActionError(null); + setActionSuccess(t("deleted")); + if (selectedFile) { + setSelectedFile(null); + setEditorContent(""); + } + invalidateListing(); + setSelectedPaths([]); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.deleteFailed")); + }, + }); + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + const content = await readFileAsBase64(file); + const targetPath = currentPath ? `${currentPath}/${file.name}` : file.name; + return uploadFile(serverId, { path: targetPath, content }); + }, + onSuccess: () => { + setActionError(null); + setActionSuccess(t("uploaded")); + invalidateListing(); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.uploadFailed")); + }, + }); + + const archiveMutation = useMutation({ + mutationFn: (paths: string[]) => archiveFiles(serverId, { paths }), + onSuccess: () => { + setActionError(null); + setActionSuccess(t("archived")); + invalidateListing(); + setSelectedPaths([]); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.archiveFailed")); + }, + }); + + const unarchiveMutation = useMutation({ + mutationFn: (archivePath: string) => + unarchiveFile(serverId, { archivePath, destinationPath: currentPath || "." }), + onSuccess: () => { + setActionError(null); + setActionSuccess(t("unarchived")); + invalidateListing(); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError( + err instanceof ApiError ? (err.detail ?? err.title) : t("errors.unarchiveFailed"), + ); + }, + }); + function handleSelectEntry(path: string, isDir: boolean) { setError(null); setSaveSuccess(false); @@ -106,6 +212,46 @@ export function ServerFiles({ serverId }: ServerFilesProps) { setError(null); } + function togglePathSelection(path: string) { + setSelectedPaths((prev) => + prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], + ); + } + + function handleDelete(path: string) { + if (!window.confirm(t("deleteConfirm", { name: path.split("/").pop() ?? path }))) { + return; + } + deleteMutation.mutate(path); + } + + function handleArchiveSelected() { + if (selectedPaths.length === 0) { + return; + } + archiveMutation.mutate(selectedPaths); + } + + function handleUnarchive() { + const archivePath = selectedFile ?? selectedPaths[0]; + if (!archivePath) { + return; + } + if (!window.confirm(t("unarchiveConfirm", { name: archivePath.split("/").pop() ?? archivePath }))) { + return; + } + unarchiveMutation.mutate(archivePath); + } + + async function handleUploadChange(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || isReadOnly) { + return; + } + uploadMutation.mutate(file); + } + if (!server) { return

{t("loading")}

; } @@ -118,18 +264,52 @@ export function ServerFiles({ serverId }: ServerFilesProps) { ); } - const isReadOnly = server.status === "STOPPED"; + const busy = + deleteMutation.isPending || + uploadMutation.isPending || + archiveMutation.isPending || + unarchiveMutation.isPending; return (
-
+

{t("browser")}

- {currentPath ? ( - - ) : null} +
+ {!isReadOnly ? ( + <> + void handleUploadChange(event)} + /> + + + ) : null} + {selectedPaths.length > 0 && !isReadOnly ? ( + + ) : null} + {(selectedFile ?? selectedPaths[0])?.endsWith(".tar.gz") && !isReadOnly ? ( + + ) : null} + {currentPath ? ( + + ) : null} +

@@ -140,6 +320,15 @@ export function ServerFiles({ serverId }: ServerFilesProps) {

{t("readOnlyStopped")}

) : null} + {actionError ? ( +

+ {actionError} +

+ ) : null} + {actionSuccess ? ( +

{actionSuccess}

+ ) : null} + {listingLoading ? (

{t("loading")}

) : listingError ? ( @@ -150,11 +339,20 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
  • {t("empty")}
  • ) : ( (listing?.entries ?? []).map((entry) => ( -
  • +
  • + {!isReadOnly ? ( + togglePathSelection(entry.path)} + aria-label={entry.name} + /> + ) : null} + {!isReadOnly ? ( + + ) : null}
  • )) )} @@ -212,10 +421,7 @@ export function ServerFiles({ serverId }: ServerFilesProps) { ) : null} {!isReadOnly ? ( - ) : null} diff --git a/apps/web/src/components/servers/server-players.tsx b/apps/web/src/components/servers/server-players.tsx index 0007448..42ef8d7 100644 --- a/apps/web/src/components/servers/server-players.tsx +++ b/apps/web/src/components/servers/server-players.tsx @@ -1,15 +1,40 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { Button } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { getPlayers } from "@/lib/api/players"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + addBan, + addOperator, + addWhitelist, + getPlayers, + kickPlayer, + removeBan, + removeOperator, + removeWhitelist, +} from "@/lib/api/players"; interface ServerPlayersProps { serverId: string; } +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300"; + export function ServerPlayers({ serverId }: ServerPlayersProps) { const t = useTranslations("servers.players"); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [whitelistName, setWhitelistName] = useState(""); + const [whitelistUuid, setWhitelistUuid] = useState(""); + const [operatorName, setOperatorName] = useState(""); + const [operatorUuid, setOperatorUuid] = useState(""); + const [banName, setBanName] = useState(""); + const [banReason, setBanReason] = useState(""); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["server-players", serverId], @@ -17,6 +42,75 @@ export function ServerPlayers({ serverId }: ServerPlayersProps) { refetchInterval: 15000, }); + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: ["server-players", serverId] }); + + const mutationOptions = { + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.actionFailed")); + }, + }; + + const addWhitelistMutation = useMutation({ + mutationFn: () => + addWhitelist(serverId, { name: whitelistName.trim(), uuid: whitelistUuid.trim() }), + ...mutationOptions, + onSuccess: () => { + mutationOptions.onSuccess(); + setWhitelistName(""); + setWhitelistUuid(""); + }, + }); + + const removeWhitelistMutation = useMutation({ + mutationFn: (uuid: string) => removeWhitelist(serverId, { uuid }), + ...mutationOptions, + }); + + const addOperatorMutation = useMutation({ + mutationFn: () => + addOperator(serverId, { name: operatorName.trim(), uuid: operatorUuid.trim() }), + ...mutationOptions, + onSuccess: () => { + mutationOptions.onSuccess(); + setOperatorName(""); + setOperatorUuid(""); + }, + }); + + const removeOperatorMutation = useMutation({ + mutationFn: (uuid: string) => removeOperator(serverId, { uuid }), + ...mutationOptions, + }); + + const addBanMutation = useMutation({ + mutationFn: () => + addBan(serverId, { + name: banName.trim(), + reason: banReason.trim() || undefined, + }), + ...mutationOptions, + onSuccess: () => { + mutationOptions.onSuccess(); + setBanName(""); + setBanReason(""); + }, + }); + + const removeBanMutation = useMutation({ + mutationFn: (name: string) => removeBan(serverId, { name }), + ...mutationOptions, + }); + + const kickMutation = useMutation({ + mutationFn: (name: string) => kickPlayer(serverId, { name }), + ...mutationOptions, + }); + if (isLoading) { return

    {t("loading")}

    ; } @@ -36,61 +130,257 @@ export function ServerPlayers({ serverId }: ServerPlayersProps) { ); } + const busy = + addWhitelistMutation.isPending || + removeWhitelistMutation.isPending || + addOperatorMutation.isPending || + removeOperatorMutation.isPending || + addBanMutation.isPending || + removeBanMutation.isPending || + kickMutation.isPending; + return ( -
    +
    + {error ? ( +

    + {error} +

    + ) : null} +

    {t("onlineCount")}

    {data.online.length} - / {data.maxPlayers}

    {t("whitelistCount")}

    - {data.whitelistCount} + {data.whitelist.length} +

    +
    +
    +

    {t("operatorsCount")}

    +

    + {data.operators.length}

    -
    - - - - - - - - - {data.online.length === 0 ? ( +
    +

    {t("onlineTitle")}

    +
    +
    - {t("columns.name")} - - {t("columns.ping")} -
    + - + + - ) : ( - data.online.map((player) => ( - - - + + {data.online.length === 0 ? ( + + - )) - )} - -
    - {t("empty")} - + {t("columns.name")} + + {t("columns.actions")} +
    - {player.name} - - {player.ping !== undefined ? `${player.ping} ms` : "—"} +
    + {t("empty")}
    -
    + ) : ( + data.online.map((player) => ( + + + {player.name} + + + + + + )) + )} + + +
    + + +
    +

    {t("whitelistTitle")}

    +
    +
    + + setWhitelistName(e.target.value)} + /> +
    +
    + + setWhitelistUuid(e.target.value)} + /> +
    +
    + + removeWhitelistMutation.mutate(uuid)} + busy={busy} + removeLabel={t("remove")} + /> +
    + +
    +

    {t("operatorsTitle")}

    +
    +
    + + setOperatorName(e.target.value)} + /> +
    +
    + + setOperatorUuid(e.target.value)} + /> +
    +
    + + removeOperatorMutation.mutate(uuid)} + busy={busy} + removeLabel={t("remove")} + /> +
    + +
    +

    {t("bansTitle")}

    +
    +
    + + setBanName(e.target.value)} + /> +
    +
    + + setBanReason(e.target.value)} + /> +
    +
    + +
      + {(data.bans ?? []).length === 0 ? ( +
    • {t("bansEmpty")}
    • + ) : ( + (data.bans ?? []).map((ban) => ( +
    • +
      +

      {ban.name}

      + {ban.reason ? ( +

      {ban.reason}

      + ) : null} +
      + +
    • + )) + )} +
    +
    ); } + +function PlayerEntryList({ + entries, + onRemove, + busy, + removeLabel, +}: { + entries: Array<{ uuid: string; name: string }>; + onRemove: (uuid: string) => void; + busy: boolean; + removeLabel: string; +}) { + if (entries.length === 0) { + return null; + } + + return ( +
      + {entries.map((entry) => ( +
    • +
      +

      {entry.name}

      +

      {entry.uuid}

      +
      + +
    • + ))} +
    + ); +} diff --git a/apps/web/src/components/servers/server-schedules.tsx b/apps/web/src/components/servers/server-schedules.tsx new file mode 100644 index 0000000..8b45a0c --- /dev/null +++ b/apps/web/src/components/servers/server-schedules.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState, Fragment } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + createSchedule, + deleteSchedule, + listSchedules, + updateSchedule, + type Schedule, + type ScheduleTaskType, +} from "@/lib/api/schedules"; + +interface ServerSchedulesProps { + serverId: string; +} + +const TASK_TYPES: ScheduleTaskType[] = [ + "START", + "STOP", + "RESTART", + "BACKUP", + "CONSOLE_COMMAND", + "UPDATE_CHECK", + "MAINTENANCE", +]; + +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300"; + +interface ScheduleFormState { + name: string; + cronExpr: string; + timezone: string; + enabled: boolean; + taskType: ScheduleTaskType; + command: string; +} + +const defaultForm: ScheduleFormState = { + name: "", + cronExpr: "0 4 * * *", + timezone: "UTC", + enabled: true, + taskType: "RESTART", + command: "", +}; + +function scheduleToForm(schedule: Schedule): ScheduleFormState { + const task = schedule.tasks[0]; + const command = + task?.taskType === "CONSOLE_COMMAND" && task.payload?.command + ? String(task.payload.command) + : ""; + return { + name: schedule.name, + cronExpr: schedule.cronExpr, + timezone: schedule.timezone, + enabled: schedule.enabled, + taskType: task?.taskType ?? "RESTART", + command, + }; +} + +function formToPayload(form: ScheduleFormState) { + const payload = + form.taskType === "CONSOLE_COMMAND" && form.command.trim() + ? { command: form.command.trim() } + : undefined; + return { + name: form.name.trim(), + cronExpr: form.cronExpr.trim(), + timezone: form.timezone.trim() || "UTC", + enabled: form.enabled, + tasks: [{ taskType: form.taskType, payload }], + }; +} + +export function ServerSchedules({ serverId }: ServerSchedulesProps) { + const t = useTranslations("servers.schedules"); + const queryClient = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(defaultForm); + const [error, setError] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["server-schedules", serverId], + queryFn: () => listSchedules(serverId), + }); + + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: ["server-schedules", serverId] }); + + const createMutation = useMutation({ + mutationFn: () => createSchedule(serverId, formToPayload(form)), + onSuccess: () => { + setError(null); + setShowForm(false); + setForm(defaultForm); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed")); + }, + }); + + const updateMutation = useMutation({ + mutationFn: (scheduleId: string) => + updateSchedule(serverId, scheduleId, formToPayload(form)), + onSuccess: () => { + setError(null); + setEditingId(null); + setForm(defaultForm); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed")); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (scheduleId: string) => deleteSchedule(serverId, scheduleId), + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.deleteFailed")); + }, + }); + + const toggleMutation = useMutation({ + mutationFn: (schedule: Schedule) => + updateSchedule(serverId, schedule.id, { enabled: !schedule.enabled }), + onSuccess: () => invalidate(), + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed")); + }, + }); + + function startCreate() { + setEditingId(null); + setForm(defaultForm); + setShowForm(true); + setError(null); + } + + function startEdit(schedule: Schedule) { + setShowForm(false); + setEditingId(schedule.id); + setForm(scheduleToForm(schedule)); + setError(null); + } + + function cancelForm() { + setShowForm(false); + setEditingId(null); + setForm(defaultForm); + setError(null); + } + + function handleDelete(schedule: Schedule) { + if (!window.confirm(t("deleteConfirm", { name: schedule.name }))) { + return; + } + deleteMutation.mutate(schedule.id); + } + + const isSaving = createMutation.isPending || updateMutation.isPending; + + function renderForm(onSubmit: () => void, submitLabel: string) { + return ( +
    +
    +
    + + setForm((prev) => ({ ...prev, name: e.target.value }))} + /> +
    +
    + + setForm((prev) => ({ ...prev, cronExpr: e.target.value }))} + /> +
    +
    + + setForm((prev) => ({ ...prev, timezone: e.target.value }))} + /> +
    +
    + + +
    +
    + setForm((prev) => ({ ...prev, enabled: e.target.checked }))} + /> + +
    + {form.taskType === "CONSOLE_COMMAND" ? ( +
    + + setForm((prev) => ({ ...prev, command: e.target.value }))} + placeholder={t("fields.commandPlaceholder")} + /> +
    + ) : null} +
    +
    + + +
    +
    + ); + } + + return ( + + +
    +
    + {t("title")} + {t("description")} +
    + {!showForm && !editingId ? ( + + ) : null} +
    +
    + + {error ? ( +

    + {error} +

    + ) : null} + + {showForm + ? renderForm(() => createMutation.mutate(), t("create")) + : null} + + {isLoading ? ( +

    {t("loading")}

    + ) : isError ? ( +

    {t("errors.loadFailed")}

    + ) : (data?.schedules ?? []).length === 0 && !showForm ? ( +

    {t("empty")}

    + ) : ( +
    + + + + + + + + + + + + {(data?.schedules ?? []).map((schedule) => ( + + + + + + + + + {editingId === schedule.id ? ( + + + + ) : null} + + ))} + +
    + {t("columns.name")} + + {t("columns.cron")} + + {t("columns.task")} + + {t("columns.status")} + + {t("columns.actions")} +
    + {schedule.name} + + {schedule.cronExpr} + + {schedule.tasks.map((task) => t(`taskTypes.${task.taskType}`)).join(", ")} + + + {schedule.enabled ? t("status.enabled") : t("status.disabled")} + + +
    + + + +
    +
    + {renderForm(() => updateMutation.mutate(schedule.id), t("save"))} +
    +
    + )} +
    +
    + ); +} diff --git a/apps/web/src/components/servers/server-software.tsx b/apps/web/src/components/servers/server-software.tsx new file mode 100644 index 0000000..aac5fc4 --- /dev/null +++ b/apps/web/src/components/servers/server-software.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { getServer } from "@/lib/api/servers"; +import { reinstallServer } from "@/lib/api/software"; + +interface ServerSoftwareProps { + serverId: string; +} + +export function ServerSoftware({ serverId }: ServerSoftwareProps) { + const t = useTranslations("servers.software"); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["server", serverId], + queryFn: () => getServer(serverId), + }); + + const reinstallMutation = useMutation({ + mutationFn: () => + reinstallServer(serverId, { + softwareFamily: data!.softwareFamily, + minecraftVersion: data!.minecraftVersion, + createBackup: true, + }), + onSuccess: () => { + setError(null); + setSuccess(true); + void queryClient.invalidateQueries({ queryKey: ["server", serverId] }); + setTimeout(() => setSuccess(false), 4000); + }, + onError: (err: unknown) => { + setSuccess(false); + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("reinstallError")); + }, + }); + + function handleReinstall() { + if (!data || !window.confirm(t("reinstallConfirm"))) { + return; + } + reinstallMutation.mutate(); + } + + if (isLoading) { + return

    {t("loading")}

    ; + } + + if (isError || !data) { + return

    {t("loadError")}

    ; + } + + const canReinstall = data.status === "STOPPED"; + + return ( + + + {t("title")} + {t("description")} + + +
    + {t("family")} + + {data.softwareFamily} + +
    +
    + {t("version")} + + {data.minecraftVersion} + +
    +
    + {t("edition")} + {data.edition} +
    + + {!canReinstall ? ( +

    {t("reinstallStoppedHint")}

    + ) : null} + + {error ? ( +

    + {error} +

    + ) : null} + + {success ? ( +

    {t("reinstallStarted")}

    + ) : null} + + +
    +
    + ); +} diff --git a/apps/web/src/components/servers/server-tabs.tsx b/apps/web/src/components/servers/server-tabs.tsx index 272e6d9..d1c5adb 100644 --- a/apps/web/src/components/servers/server-tabs.tsx +++ b/apps/web/src/components/servers/server-tabs.tsx @@ -12,10 +12,13 @@ const tabs = [ { key: "console", suffix: "/console" }, { key: "files", suffix: "/files" }, { key: "properties", suffix: "/properties" }, + { key: "software", suffix: "/software" }, { key: "players", suffix: "/players" }, { key: "worlds", suffix: "/worlds" }, { key: "backups", suffix: "/backups" }, { key: "addons", suffix: "/addons" }, + { key: "schedules", suffix: "/schedules" }, + { key: "access", suffix: "/access" }, ] as const; export function ServerTabs({ serverId }: ServerTabsProps) { diff --git a/apps/web/src/lib/api/admin.ts b/apps/web/src/lib/api/admin.ts new file mode 100644 index 0000000..51c695b --- /dev/null +++ b/apps/web/src/lib/api/admin.ts @@ -0,0 +1,143 @@ +import { apiFetch } from "./auth"; + +export interface AdminDashboardStats { + activeUsers: number; + totalServers: number; + runningServers: number; + queueLength: number; + onlineNodes: number; + failedJobs24h: number; + openAbuseReports: number; + openTickets: number; + generatedAt: string; +} + +export interface AdminUser { + id: string; + email: string; + username: string; + displayName: string | null; + status: string; + roles: string[]; + twoFactorEnabled: boolean; + sessionCount: number; + serverCount: number; + createdAt: string; +} + +export interface AdminJob { + id: string; + queue: string; + jobName: string; + status: string; + attempts: number; + error: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; +} + +export interface AdminAuditEvent { + id: string; + action: string; + entityType: string | null; + entityId: string | null; + metadata: unknown; + ipAddress: string | null; + createdAt: string; + user: { id: string; email: string; username: string } | null; +} + +export interface GameNodeSummary { + id: string; + name: string; + hostname: string; + status: string; + maxServers: number; + maxRamMb: number; + platformRamMb: number; + activeServers: number; + reservedRamMb: number; + availableRamMb: number; + lastHeartbeatAt: string | null; + agentVersion: string | null; + drainRequestedAt: string | null; + createdAt: string; +} + +export async function getAdminDashboardStats(): Promise { + return apiFetch("/admin/dashboard"); +} + +export async function listAdminNodes(): Promise<{ nodes: GameNodeSummary[] }> { + return apiFetch<{ nodes: GameNodeSummary[] }>("/admin/nodes"); +} + +export async function drainAdminNode(nodeId: string): Promise { + return apiFetch(`/admin/nodes/${nodeId}/drain`, { + method: "POST", + }); +} + +export async function setAdminNodeMaintenance( + nodeId: string, + enabled: boolean, +): Promise { + return apiFetch(`/admin/nodes/${nodeId}/maintenance`, { + method: "POST", + body: JSON.stringify({ enabled }), + }); +} + +export async function searchAdminUsers(params?: { + q?: string; + limit?: number; +}): Promise<{ users: AdminUser[] }> { + const search = new URLSearchParams(); + if (params?.q) search.set("q", params.q); + if (params?.limit) search.set("limit", String(params.limit)); + const query = search.toString(); + return apiFetch<{ users: AdminUser[] }>( + `/admin/users${query ? `?${query}` : ""}`, + ); +} + +export async function suspendAdminUser(userId: string): Promise<{ id: string; status: string }> { + return apiFetch<{ id: string; status: string }>(`/admin/users/${userId}/suspend`, { + method: "POST", + }); +} + +export async function unsuspendAdminUser(userId: string): Promise<{ id: string; status: string }> { + return apiFetch<{ id: string; status: string }>(`/admin/users/${userId}/unsuspend`, { + method: "POST", + }); +} + +export async function listAdminJobs(params?: { + limit?: number; + status?: string; + queue?: string; +}): Promise<{ jobs: AdminJob[] }> { + const search = new URLSearchParams(); + if (params?.limit) search.set("limit", String(params.limit)); + if (params?.status) search.set("status", params.status); + if (params?.queue) search.set("queue", params.queue); + const query = search.toString(); + return apiFetch<{ jobs: AdminJob[] }>(`/admin/jobs${query ? `?${query}` : ""}`); +} + +export async function listAdminAuditEvents(params?: { + limit?: number; + cursor?: string; + action?: string; +}): Promise<{ items: AdminAuditEvent[]; nextCursor: string | null }> { + const search = new URLSearchParams(); + if (params?.limit) search.set("limit", String(params.limit)); + if (params?.cursor) search.set("cursor", params.cursor); + if (params?.action) search.set("action", params.action); + const query = search.toString(); + return apiFetch<{ items: AdminAuditEvent[]; nextCursor: string | null }>( + `/admin/audit${query ? `?${query}` : ""}`, + ); +} diff --git a/apps/web/src/lib/api/auth.ts b/apps/web/src/lib/api/auth.ts index 1344c20..6294db4 100644 --- a/apps/web/src/lib/api/auth.ts +++ b/apps/web/src/lib/api/auth.ts @@ -102,6 +102,40 @@ async function parseProblemResponse(response: Response): Promise { }); } +let csrfToken: string | null = null; + +const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +async function fetchCsrfToken(): Promise { + const endpoint = `${getApiUrl()}/auth/csrf`; + + const response = await fetch(endpoint, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to fetch CSRF token"); + } + + const data = (await response.json()) as { token: string }; + csrfToken = data.token; + return data.token; +} + +async function ensureCsrfToken(method: string): Promise { + if (!MUTATION_METHODS.has(method.toUpperCase())) { + return; + } + + if (!csrfToken) { + await fetchCsrfToken(); + } +} + +export function clearCsrfToken(): void { + csrfToken = null; +} + export async function apiFetch( path: string, options: RequestInit = {}, @@ -112,12 +146,19 @@ export async function apiFetch( throw new Error("Invalid API URL configuration"); } + const method = (options.method ?? "GET").toUpperCase(); + await ensureCsrfToken(method); + const headers = new Headers(options.headers); if (options.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } + if (MUTATION_METHODS.has(method) && csrfToken) { + headers.set("x-csrf-token", csrfToken); + } + const response = await fetch(endpoint, { ...options, credentials: "include", diff --git a/apps/web/src/lib/api/compliance.ts b/apps/web/src/lib/api/compliance.ts new file mode 100644 index 0000000..139c27c --- /dev/null +++ b/apps/web/src/lib/api/compliance.ts @@ -0,0 +1,47 @@ +import { apiFetch } from "./auth"; + +export interface DataExportRequest { + id: string; + status: "PENDING" | "PROCESSING" | "READY" | "FAILED" | "EXPIRED"; + downloadUrl: string | null; + expiresAt: string | null; + completedAt: string | null; + createdAt: string; +} + +export interface AccountDeletionRequest { + id: string; + status: "PENDING" | "SCHEDULED" | "COMPLETED" | "CANCELLED"; + scheduledFor: string; + completedAt: string | null; + createdAt: string; +} + +export async function requestDataExport(): Promise { + return apiFetch("/account/compliance/export", { + method: "POST", + }); +} + +export async function listDataExports(): Promise<{ exports: DataExportRequest[] }> { + return apiFetch("/account/compliance/export"); +} + +export async function requestAccountDeletion( + confirmEmail: string, +): Promise { + return apiFetch("/account/compliance/deletion", { + method: "POST", + body: JSON.stringify({ confirmEmail }), + }); +} + +export async function getAccountDeletionStatus(): Promise { + return apiFetch("/account/compliance/deletion"); +} + +export async function cancelAccountDeletion(): Promise { + return apiFetch("/account/compliance/deletion", { + method: "DELETE", + }); +} diff --git a/apps/web/src/lib/api/files.ts b/apps/web/src/lib/api/files.ts index e05b0f0..b131818 100644 --- a/apps/web/src/lib/api/files.ts +++ b/apps/web/src/lib/api/files.ts @@ -35,9 +35,28 @@ export async function listFiles( params.set("path", path); } const query = params.toString(); - return apiFetch( - `/servers/${serverId}/files${query ? `?${query}` : ""}`, - ); + const response = await apiFetch<{ + path: string; + entries: Array<{ + name: string; + path: string; + type?: "file" | "directory"; + isDir?: boolean; + size?: number; + modifiedAt?: string; + }>; + }>(`/servers/${serverId}/files${query ? `?${query}` : ""}`); + + return { + path: response.path, + entries: response.entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDir: entry.isDir ?? entry.type === "directory", + size: entry.size ?? 0, + modifiedAt: entry.modifiedAt, + })), + }; } export async function readFile( @@ -59,3 +78,62 @@ export async function writeFile( body: JSON.stringify(payload), }); } + +export interface UploadFilePayload { + path: string; + content: string; + encoding?: "utf-8" | "base64"; +} + +export interface ArchiveFilesPayload { + paths: string[]; + archiveName?: string; +} + +export interface UnarchiveFilePayload { + archivePath: string; + destinationPath?: string; +} + +export async function deleteFile( + serverId: string, + path: string, +): Promise { + const params = new URLSearchParams({ path }); + await apiFetch(`/servers/${serverId}/files?${params.toString()}`, { + method: "DELETE", + }); +} + +export async function uploadFile( + serverId: string, + payload: UploadFilePayload, +): Promise<{ path: string }> { + return apiFetch(`/servers/${serverId}/files/upload`, { + method: "POST", + body: JSON.stringify({ + encoding: "base64", + ...payload, + }), + }); +} + +export async function archiveFiles( + serverId: string, + payload: ArchiveFilesPayload, +): Promise<{ archivePath: string }> { + return apiFetch(`/servers/${serverId}/files/archive`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function unarchiveFile( + serverId: string, + payload: UnarchiveFilePayload, +): Promise<{ jobId?: string }> { + return apiFetch(`/servers/${serverId}/files/unarchive`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/apps/web/src/lib/api/members.ts b/apps/web/src/lib/api/members.ts new file mode 100644 index 0000000..61dd0a1 --- /dev/null +++ b/apps/web/src/lib/api/members.ts @@ -0,0 +1,52 @@ +import { apiFetch } from "./auth"; + +export type MemberRole = "ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER"; + +export type ServerMemberRole = MemberRole | "OWNER"; + +export interface ServerMember { + id: string; + userId: string; + role: ServerMemberRole; + email: string; + username: string; + displayName: string | null; + acceptedAt: string | null; +} + +export interface MemberListResponse { + members: ServerMember[]; +} + +export interface InviteMemberPayload { + email: string; + role: MemberRole; +} + +export interface InviteMemberResponse { + status?: string; + message?: string; + id?: string; + userId?: string; + role?: string; +} + +export async function listMembers(serverId: string): Promise { + return apiFetch(`/servers/${serverId}/members`); +} + +export async function inviteMember( + serverId: string, + payload: InviteMemberPayload, +): Promise { + return apiFetch(`/servers/${serverId}/members`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeMember(serverId: string, memberId: string): Promise { + await apiFetch(`/servers/${serverId}/members/${memberId}`, { + method: "DELETE", + }); +} diff --git a/apps/web/src/lib/api/notifications.ts b/apps/web/src/lib/api/notifications.ts new file mode 100644 index 0000000..e0e0284 --- /dev/null +++ b/apps/web/src/lib/api/notifications.ts @@ -0,0 +1,75 @@ +import { apiFetch } from "./auth"; + +export type NotificationType = + | "SERVER_READY" + | "SERVER_START_FAILED" + | "SERVER_STOPPED" + | "IDLE_STOP_WARNING" + | "BACKUP_SUCCESS" + | "BACKUP_FAILED" + | "RESTORE_SUCCESS" + | "RESTORE_FAILED" + | "CREDITS_LOW" + | "BILLING_ISSUE" + | "INVITE" + | "NODE_INCIDENT" + | "MAINTENANCE" + | "SECURITY" + | "NEW_LOGIN" + | "TWO_FA_CHANGED"; + +export type NotificationChannel = "IN_APP" | "EMAIL" | "WEBHOOK" | "DISCORD"; + +export interface Notification { + id: string; + type: NotificationType; + channel: NotificationChannel; + title: string; + body: string; + readAt: string | null; + createdAt: string; +} + +export interface NotificationListResponse { + notifications: Notification[]; + unreadCount: number; +} + +export interface NotificationPreference { + type: NotificationType; + channel: NotificationChannel; + enabled: boolean; +} + +export interface NotificationPreferencesResponse { + preferences: NotificationPreference[]; +} + +export async function listNotifications(): Promise { + return apiFetch("/account/notifications"); +} + +export async function markNotificationRead(id: string): Promise<{ read: boolean }> { + return apiFetch(`/account/notifications/${id}/read`, { + method: "POST", + }); +} + +export async function markAllNotificationsRead(): Promise<{ read: boolean }> { + return apiFetch("/account/notifications/read-all", { + method: "POST", + }); +} + +export async function getNotificationPreferences(): Promise { + return apiFetch("/account/notifications/preferences"); +} + +export async function updateNotificationPreferences( + preferences: NotificationPreference[], +): Promise { + return apiFetch("/account/notifications/preferences", { + method: "PATCH", + body: JSON.stringify({ preferences }), + }); +} diff --git a/apps/web/src/lib/api/players.ts b/apps/web/src/lib/api/players.ts index b0be45f..78abe52 100644 --- a/apps/web/src/lib/api/players.ts +++ b/apps/web/src/lib/api/players.ts @@ -6,12 +6,124 @@ export interface OnlinePlayer { ping?: number; } +export interface WhitelistEntry { + uuid: string; + name: string; +} + +export interface OperatorEntry { + uuid: string; + name: string; + level?: number; + bypassesPlayerLimit?: boolean; +} + +export interface BanEntry { + uuid?: string; + name: string; + reason?: string; + source?: string; + expires?: string; +} + export interface PlayerListResponse { online: OnlinePlayer[]; - maxPlayers: number; - whitelistCount: number; + whitelist: WhitelistEntry[]; + operators: OperatorEntry[]; + bans?: BanEntry[]; +} + +export interface AddWhitelistPayload { + uuid: string; + name: string; +} + +export interface AddOperatorPayload { + uuid: string; + name: string; + level?: number; + bypassesPlayerLimit?: boolean; +} + +export interface RemovePlayerPayload { + uuid: string; +} + +export interface AddBanPayload { + name: string; + reason?: string; } export async function getPlayers(serverId: string): Promise { return apiFetch(`/servers/${serverId}/players`); } + +export async function addWhitelist( + serverId: string, + payload: AddWhitelistPayload, +): Promise<{ whitelist: WhitelistEntry[] }> { + return apiFetch(`/servers/${serverId}/players/whitelist`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeWhitelist( + serverId: string, + payload: RemovePlayerPayload, +): Promise<{ whitelist: WhitelistEntry[] }> { + return apiFetch(`/servers/${serverId}/players/whitelist`, { + method: "DELETE", + body: JSON.stringify(payload), + }); +} + +export async function addOperator( + serverId: string, + payload: AddOperatorPayload, +): Promise<{ operators: OperatorEntry[] }> { + return apiFetch(`/servers/${serverId}/players/operators`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeOperator( + serverId: string, + payload: RemovePlayerPayload, +): Promise<{ operators: OperatorEntry[] }> { + return apiFetch(`/servers/${serverId}/players/operators`, { + method: "DELETE", + body: JSON.stringify(payload), + }); +} + +export async function addBan( + serverId: string, + payload: AddBanPayload, +): Promise<{ bans: BanEntry[] }> { + return apiFetch(`/servers/${serverId}/players/bans`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeBan( + serverId: string, + payload: { name: string }, +): Promise<{ bans: BanEntry[] }> { + return apiFetch(`/servers/${serverId}/players/bans`, { + method: "DELETE", + body: JSON.stringify(payload), + }); +} + +export async function kickPlayer( + serverId: string, + payload: AddBanPayload, +): Promise<{ kicked: boolean }> { + return apiFetch(`/servers/${serverId}/players/kick`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/apps/web/src/lib/api/schedules.ts b/apps/web/src/lib/api/schedules.ts new file mode 100644 index 0000000..a735abb --- /dev/null +++ b/apps/web/src/lib/api/schedules.ts @@ -0,0 +1,78 @@ +import { apiFetch } from "./auth"; + +export type ScheduleTaskType = + | "START" + | "STOP" + | "RESTART" + | "BACKUP" + | "CONSOLE_COMMAND" + | "UPDATE_CHECK" + | "MAINTENANCE"; + +export interface ScheduledTask { + id: string; + taskType: ScheduleTaskType; + payload: Record | null; +} + +export interface Schedule { + id: string; + name: string; + timezone: string; + cronExpr: string; + enabled: boolean; + nextRunAt: string | null; + lastRunAt: string | null; + tasks: ScheduledTask[]; +} + +export interface ScheduleListResponse { + schedules: Schedule[]; +} + +export interface CreateSchedulePayload { + name: string; + timezone?: string; + cronExpr: string; + enabled?: boolean; + tasks: Array<{ + taskType: ScheduleTaskType; + payload?: Record; + }>; +} + +export type UpdateSchedulePayload = Partial; + +export async function listSchedules(serverId: string): Promise { + return apiFetch(`/servers/${serverId}/schedules`); +} + +export async function createSchedule( + serverId: string, + payload: CreateSchedulePayload, +): Promise { + return apiFetch(`/servers/${serverId}/schedules`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateSchedule( + serverId: string, + scheduleId: string, + payload: UpdateSchedulePayload, +): Promise { + return apiFetch(`/servers/${serverId}/schedules/${scheduleId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function deleteSchedule( + serverId: string, + scheduleId: string, +): Promise { + await apiFetch(`/servers/${serverId}/schedules/${scheduleId}`, { + method: "DELETE", + }); +} diff --git a/apps/web/src/lib/api/software.ts b/apps/web/src/lib/api/software.ts new file mode 100644 index 0000000..2803b94 --- /dev/null +++ b/apps/web/src/lib/api/software.ts @@ -0,0 +1,27 @@ +import { apiFetch } from "./auth"; +import type { SoftwareFamily } from "./servers"; + +export interface ReinstallServerPayload { + softwareFamily: SoftwareFamily; + minecraftVersion: string; + createBackup?: boolean; +} + +export interface ReinstallServerResponse { + serverId: string; + jobId: string; + status: string; +} + +export async function reinstallServer( + serverId: string, + payload: ReinstallServerPayload, +): Promise { + return apiFetch(`/servers/${serverId}/software/reinstall`, { + method: "POST", + body: JSON.stringify({ + createBackup: true, + ...payload, + }), + }); +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 5609f21..8a7d796 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -4,7 +4,15 @@ import { routing } from "./i18n/routing"; const intlMiddleware = createMiddleware(routing); -const protectedPaths = ["/dashboard", "/security", "/servers"]; +const protectedPaths = [ + "/dashboard", + "/security", + "/servers", + "/billing", + "/profile", + "/notifications", + "/admin", +]; function getPathWithoutLocale(pathname: string): string { if (pathname === "/en" || pathname.startsWith("/en/")) { @@ -22,7 +30,31 @@ function buildLocalizedPath(path: string, pathname: string): string { return path; } -export default function middleware(request: NextRequest) { +function getApiUrl(): string { + return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api/v1"; +} + +async function fetchCurrentUserRoles( + sessionValue: string, +): Promise { + try { + const response = await fetch(`${getApiUrl()}/me`, { + headers: { Cookie: `hgc_session=${sessionValue}` }, + cache: "no-store", + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { user?: { roles?: string[] } }; + return data.user?.roles ?? null; + } catch { + return null; + } +} + +export default async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const pathWithoutLocale = getPathWithoutLocale(pathname); @@ -41,6 +73,20 @@ export default function middleware(request: NextRequest) { loginUrl.searchParams.set("redirect", pathname); return NextResponse.redirect(loginUrl); } + + const isAdminRoute = + pathWithoutLocale === "/admin" || + pathWithoutLocale.startsWith("/admin/"); + + if (isAdminRoute) { + const roles = await fetchCurrentUserRoles(session.value); + + if (!roles?.includes("SUPER_ADMIN")) { + const dashboardUrl = request.nextUrl.clone(); + dashboardUrl.pathname = buildLocalizedPath("/dashboard", pathname); + return NextResponse.redirect(dashboardUrl); + } + } } return intlMiddleware(request); diff --git a/apps/worker/package.json b/apps/worker/package.json index f1234c4..566f38b 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -16,9 +16,13 @@ "@hexahost/config": "workspace:*", "@hexahost/database": "workspace:*", "@hexahost/dns": "workspace:*", + "@hexahost/email-templates": "workspace:*", "@hexahost/metering": "workspace:*", "@hexahost/scheduler": "workspace:*", + "@hexahost/server-state": "workspace:*", "@hexahost/storage": "workspace:*", + "@aws-sdk/client-s3": "^3.758.0", + "cron-parser": "^5.0.4", "bullmq": "^5.34.10", "ioredis": "^5.4.2", "nodemailer": "^6.10.0", diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index 86f8239..e4a7f3e 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -1,7 +1,13 @@ import { z } from 'zod'; export const notificationEmailJobSchema = z.object({ - type: z.enum(['email_verification', 'password_reset']), + type: z.enum([ + 'email_verification', + 'password_reset', + 'server_ready', + 'backup_success', + 'backup_failed', + ]), to: z.string().email(), subject: z.string(), template: z.string(), diff --git a/apps/worker/src/handlers/notification-dispatch.ts b/apps/worker/src/handlers/notification-dispatch.ts new file mode 100644 index 0000000..234dcf6 --- /dev/null +++ b/apps/worker/src/handlers/notification-dispatch.ts @@ -0,0 +1,106 @@ +import { Job } from 'bullmq'; +import { z } from 'zod'; + +import { renderBackupFailedEmail, renderBackupSuccessEmail, renderServerReadyEmail } from '@hexahost/email-templates'; + +import { getSmtpConfig } from '../config'; +import { sendEmail } from '../email'; +import { logger } from '../logger'; + +const dispatchJobSchema = z.object({ + userId: z.string().uuid(), + type: z.string(), + channel: z.enum(['EMAIL', 'WEBHOOK', 'DISCORD']), + title: z.string(), + body: z.string(), + metadata: z.record(z.unknown()).optional(), +}); + +async function postWebhook(url: string, payload: Record): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Webhook delivery failed: ${response.status}`); + } +} + +async function postDiscord(webhookUrl: string, content: string): Promise { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content.slice(0, 1900) }), + }); + if (!response.ok) { + throw new Error(`Discord delivery failed: ${response.status}`); + } +} + +export async function processNotificationDispatchJob(job: Job): Promise<{ delivered: boolean }> { + if (job.name !== 'dispatch-notification') { + return { delivered: false }; + } + + const payload = dispatchJobSchema.parse(job.data); + const config = getSmtpConfig(); + const locale = payload.metadata?.['locale'] === 'de' ? 'de' : 'en'; + const serverName = String(payload.metadata?.['serverName'] ?? 'Server'); + + if (payload.channel === 'EMAIL') { + const userEmail = payload.metadata?.['email']; + if (typeof userEmail !== 'string') { + logger.warn({ jobId: job.id }, 'Email notification missing recipient'); + return { delivered: false }; + } + + let subject = payload.title; + let html = `

    ${payload.body}

    `; + + if (payload.type === 'SERVER_READY') { + const rendered = renderServerReadyEmail({ appName: config.APP_NAME, serverName, locale }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'BACKUP_SUCCESS') { + const rendered = renderBackupSuccessEmail({ appName: config.APP_NAME, serverName, locale }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'BACKUP_FAILED') { + const rendered = renderBackupFailedEmail({ appName: config.APP_NAME, serverName, locale }); + subject = rendered.subject; + html = rendered.html; + } + + await sendEmail({ to: userEmail, subject, html }); + return { delivered: true }; + } + + if (payload.channel === 'WEBHOOK') { + const url = process.env['NOTIFICATION_WEBHOOK_URL']; + if (!url) { + logger.warn('NOTIFICATION_WEBHOOK_URL not configured'); + return { delivered: false }; + } + await postWebhook(url, { + userId: payload.userId, + type: payload.type, + title: payload.title, + body: payload.body, + metadata: payload.metadata ?? {}, + }); + return { delivered: true }; + } + + if (payload.channel === 'DISCORD') { + const url = process.env['NOTIFICATION_DISCORD_WEBHOOK_URL']; + if (!url) { + logger.warn('NOTIFICATION_DISCORD_WEBHOOK_URL not configured'); + return { delivered: false }; + } + await postDiscord(url, `**${payload.title}**\n${payload.body}`); + return { delivered: true }; + } + + return { delivered: false }; +} diff --git a/apps/worker/src/handlers/notifications.ts b/apps/worker/src/handlers/notifications.ts index d636fe3..249bea0 100644 --- a/apps/worker/src/handlers/notifications.ts +++ b/apps/worker/src/handlers/notifications.ts @@ -1,9 +1,22 @@ import { Job } from 'bullmq'; +import { + renderBackupFailedEmail, + renderBackupSuccessEmail, + renderResetEmail, + renderServerReadyEmail, + renderVerifyEmail, + type EmailLocale, +} from '@hexahost/email-templates'; + import { getSmtpConfig, notificationEmailJobSchema } from '../config'; import { sendEmail } from '../email'; import { logger } from '../logger'; +function localeFromData(data: Record): EmailLocale { + return data['locale'] === 'de' ? 'de' : 'en'; +} + export async function processNotificationJob(job: Job): Promise<{ sent: boolean }> { if (job.name !== 'send-email') { logger.warn({ jobName: job.name }, 'Unknown notification job'); @@ -12,19 +25,58 @@ export async function processNotificationJob(job: Job): Promise<{ sent: boolean const payload = notificationEmailJobSchema.parse(job.data); const config = getSmtpConfig(); + const locale = localeFromData(payload.data); + let subject = payload.subject; let html: string; + if (payload.type === 'email_verification') { - const verifyUrl = String(payload.data['verifyUrl'] ?? ''); - html = `

    Welcome to ${config.APP_NAME}!

    Verify your email address

    This link expires in 24 hours.

    `; + const rendered = renderVerifyEmail({ + appName: config.APP_NAME, + url: String(payload.data['verifyUrl'] ?? ''), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'password_reset') { + const rendered = renderResetEmail({ + appName: config.APP_NAME, + url: String(payload.data['resetUrl'] ?? ''), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'server_ready') { + const rendered = renderServerReadyEmail({ + appName: config.APP_NAME, + serverName: String(payload.data['serverName'] ?? 'Server'), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'backup_success') { + const rendered = renderBackupSuccessEmail({ + appName: config.APP_NAME, + serverName: String(payload.data['serverName'] ?? 'Server'), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'backup_failed') { + const rendered = renderBackupFailedEmail({ + appName: config.APP_NAME, + serverName: String(payload.data['serverName'] ?? 'Server'), + locale, + }); + subject = rendered.subject; + html = rendered.html; } else { - const resetUrl = String(payload.data['resetUrl'] ?? ''); - html = `

    You requested a password reset for ${config.APP_NAME}.

    Set a new password

    If you did not request this, ignore this email.

    `; + html = `

    ${payload.subject}

    `; } await sendEmail({ to: payload.to, - subject: payload.subject, + subject, html, }); diff --git a/apps/worker/src/handlers/schedules-compliance.ts b/apps/worker/src/handlers/schedules-compliance.ts new file mode 100644 index 0000000..cc741f8 --- /dev/null +++ b/apps/worker/src/handlers/schedules-compliance.ts @@ -0,0 +1,392 @@ +import { randomUUID } from 'node:crypto'; + +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { CronExpressionParser } from 'cron-parser'; + +import { searchModrinthProjects } from '@hexahost/catalog'; +import { getConfig } from '@hexahost/config'; +import { prisma } from '@hexahost/database'; +import type { GameServerStatus } from '@hexahost/server-state'; +import { ALLOWED_TRANSITIONS } from '@hexahost/server-state'; +import { + createS3Client, + loadStorageConfig, +} from '@hexahost/storage'; + +import { logger } from '../logger'; +import { getBackupsQueue, getLifecycleQueue } from '../queues'; + +export async function processScheduleTick(): Promise { + const now = new Date(); + const due = await prisma.schedule.findMany({ + where: { + enabled: true, + nextRunAt: { lte: now }, + }, + include: { tasks: true, server: true }, + take: 50, + }); + + if (due.length === 0) { + return; + } + + const lifecycleQueue = getLifecycleQueue(); + + for (const schedule of due) { + for (const task of schedule.tasks) { + const correlationId = `${schedule.id}:${task.id}:${now.getTime()}`; + if (task.taskType === 'START') { + await lifecycleQueue.add('start-server', { + serverId: schedule.serverId, + action: 'start', + correlationId, + }); + } else if (task.taskType === 'STOP') { + await lifecycleQueue.add('stop-server', { + serverId: schedule.serverId, + action: 'stop', + correlationId, + }); + } else if (task.taskType === 'RESTART') { + await lifecycleQueue.add('restart-server', { + serverId: schedule.serverId, + action: 'restart', + correlationId, + }); + } else if (task.taskType === 'BACKUP') { + const backupsQueue = getBackupsQueue(); + await backupsQueue.add('create-backup', { + serverId: schedule.serverId, + type: 'SCHEDULED', + }); + } else if (task.taskType === 'CONSOLE_COMMAND') { + const command = + typeof task.payload === 'object' && + task.payload !== null && + 'command' in task.payload && + typeof (task.payload as { command: unknown }).command === 'string' + ? (task.payload as { command: string }).command + : null; + if (command) { + await lifecycleQueue.add('console-command', { + serverId: schedule.serverId, + command, + correlationId, + }); + } + } + } + + const nextRunAt = computeNextScheduleRun(schedule.cronExpr, schedule.timezone); + await prisma.schedule.update({ + where: { id: schedule.id }, + data: { lastRunAt: now, nextRunAt }, + }); + } + + logger.info({ count: due.length }, 'Processed due schedules'); +} + +function computeNextScheduleRun(cronExpr: string, timezone: string): Date { + try { + return CronExpressionParser.parse(cronExpr, { tz: timezone, currentDate: new Date() }) + .next() + .toDate(); + } catch { + const fallback = new Date(); + fallback.setMinutes(fallback.getMinutes() + 5); + return fallback; + } +} + +export async function processCatalogSyncTick(): Promise { + const versions = ['1.21.1', '1.21', '1.20.6', '1.20.4']; + for (const version of versions) { + await prisma.minecraftVersion.upsert({ + where: { version }, + create: { version, edition: 'JAVA', isActive: true }, + update: { isActive: true }, + }); + } + + await prisma.catalogProvider.upsert({ + where: { kind: 'MODRINTH' }, + create: { kind: 'MODRINTH', name: 'Modrinth', isEnabled: true }, + update: { isEnabled: true, lastSyncAt: new Date() }, + }); + + try { + const result = await searchModrinthProjects({ + query: 'fabric api', + projectType: 'mod', + gameVersion: '1.21.1', + loader: 'fabric', + limit: 5, + }); + const provider = await prisma.catalogProvider.findUnique({ where: { kind: 'MODRINTH' } }); + if (provider) { + for (const hit of result.hits.slice(0, 5)) { + await prisma.catalogProject.upsert({ + where: { + providerId_externalId: { + providerId: provider.id, + externalId: hit.project_id, + }, + }, + create: { + providerId: provider.id, + externalId: hit.project_id, + slug: hit.slug, + name: hit.title, + projectType: hit.project_type, + metadata: { downloads: hit.downloads }, + }, + update: { + slug: hit.slug, + name: hit.title, + syncedAt: new Date(), + }, + }); + } + } + } catch (error) { + logger.warn({ error }, 'Modrinth catalog sync skipped'); + } + + logger.info({ versions: versions.length }, 'Catalog sync tick completed'); +} + +const EXPORT_TTL_DAYS = 7; +const EXPORT_BLOB_PREFIX = 'export:blob:'; + +function isStorageConfigured(): boolean { + return Boolean(process.env['S3_ENDPOINT']?.trim()); +} + +function exportObjectKey(userId: string, exportId: string): string { + return `users/${userId}/exports/${exportId}.json`; +} + +export async function processComplianceTick(): Promise { + const config = getConfig(); + const redis = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null }); + const lifecycleQueue = new Queue('server-lifecycle', { + connection: { url: config.REDIS_URL, maxRetriesPerRequest: null }, + }); + + try { + const exports = await prisma.dataExportRequest.findMany({ + where: { status: 'PENDING' }, + take: 10, + }); + + for (const row of exports) { + await processDataExport(row.id, redis); + } + + const deletions = await prisma.accountDeletionRequest.findMany({ + where: { status: 'SCHEDULED', scheduledFor: { lte: new Date() } }, + take: 10, + }); + + for (const row of deletions) { + await executeAccountDeletion(row.id, lifecycleQueue); + } + + logger.info( + { exports: exports.length, deletions: deletions.length }, + 'Compliance tick completed', + ); + } finally { + await lifecycleQueue.close(); + redis.disconnect(); + } +} + +async function processDataExport(exportId: string, redis: Redis): Promise { + const row = await prisma.dataExportRequest.findUnique({ where: { id: exportId } }); + if (!row || row.status !== 'PENDING') { + return; + } + + await prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'PROCESSING' }, + }); + + try { + const payload = await collectUserExportData(row.userId); + const json = JSON.stringify(payload, null, 2); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + EXPORT_TTL_DAYS); + + let downloadUrl: string; + + if (isStorageConfigured()) { + const storageConfig = loadStorageConfig(); + const client = createS3Client(storageConfig); + const key = exportObjectKey(row.userId, exportId); + await client.send( + new PutObjectCommand({ + Bucket: storageConfig.S3_BUCKET, + Key: key, + Body: json, + ContentType: 'application/json', + }), + ); + downloadUrl = `s3://${storageConfig.S3_BUCKET}/${key}`; + } else { + await redis.set( + `${EXPORT_BLOB_PREFIX}${exportId}`, + json, + 'EX', + EXPORT_TTL_DAYS * 24 * 60 * 60, + ); + const appConfig = getConfig(); + downloadUrl = `${appConfig.API_URL}/account/compliance/export/${exportId}/download`; + } + + await prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { + status: 'READY', + downloadUrl, + expiresAt, + completedAt: new Date(), + }, + }); + } catch (error) { + logger.error({ exportId, error }, 'Data export failed'); + await prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'FAILED' }, + }); + } +} + +async function executeAccountDeletion(requestId: string, lifecycleQueue: Queue): Promise { + const request = await prisma.accountDeletionRequest.findUnique({ + where: { id: requestId }, + }); + if (!request || request.status !== 'SCHEDULED') { + return; + } + + const userId = request.userId; + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.status === 'DELETED') { + await prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }); + return; + } + + const servers = await prisma.gameServer.findMany({ + where: { userId, status: { not: 'DELETED' } }, + }); + + for (const server of servers) { + if (server.status === 'RUNNING' || server.status === 'STARTING') { + continue; + } + + if (ALLOWED_TRANSITIONS[server.status as GameServerStatus]?.includes('DELETING')) { + const correlationId = randomUUID(); + await prisma.gameServer.update({ + where: { id: server.id }, + data: { status: 'DELETING' }, + }); + await lifecycleQueue.add( + 'delete-server', + { serverId: server.id, action: 'delete', correlationId }, + { jobId: correlationId }, + ); + } + } + + const anonymizedId = randomUUID().slice(0, 8); + await prisma.$transaction([ + prisma.userSession.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + prisma.user.update({ + where: { id: userId }, + data: { + email: `deleted-${anonymizedId}@deleted.invalid`, + username: `deleted-${anonymizedId}`, + displayName: null, + status: 'DELETED', + }, + }), + prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }), + ]); +} + +async function collectUserExportData(userId: string) { + const [user, servers, auditEvents] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + displayName: true, + status: true, + emailVerifiedAt: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.gameServer.findMany({ + where: { userId }, + select: { + id: true, + name: true, + status: true, + edition: true, + softwareFamily: true, + minecraftVersion: true, + ramMb: true, + hostPort: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + }), + prisma.auditEvent.findMany({ + where: { userId }, + select: { + action: true, + entityType: true, + entityId: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: 500, + }), + ]); + + const actionCounts = auditEvents.reduce>((acc, event) => { + acc[event.action] = (acc[event.action] ?? 0) + 1; + return acc; + }, {}); + + return { + exportedAt: new Date().toISOString(), + profile: user, + servers, + auditSummary: { + totalEvents: auditEvents.length, + actionCounts, + recentEvents: auditEvents.slice(0, 50), + }, + }; +} diff --git a/apps/worker/src/handlers/server-lifecycle.ts b/apps/worker/src/handlers/server-lifecycle.ts index 8ee788a..34c7b43 100644 --- a/apps/worker/src/handlers/server-lifecycle.ts +++ b/apps/worker/src/handlers/server-lifecycle.ts @@ -170,8 +170,31 @@ export async function processLifecycleJob(job: Job): Promise<{ status: string }> return processStartServerJob(job); case 'stop-server': return processStopServerJob(job); + case 'kill-server': + return processKillServerJob(job); + case 'delete-server': + return processDeleteServerJob(job); default: logger.warn({ jobName: job.name }, 'Unknown lifecycle job name'); return { status: 'ignored' }; } } + +async function processKillServerJob(job: Job): Promise<{ status: string }> { + const { serverId } = lifecycleJobSchema.parse(job.data); + await transitionServerStatus(serverId, 'STOPPED', { + reason: 'Kill completed', + correlationId: job.id ?? undefined, + }); + return { status: 'killed' }; +} + +async function processDeleteServerJob(job: Job): Promise<{ status: string }> { + const { serverId } = lifecycleJobSchema.parse(job.data); + await transitionServerStatus(serverId, 'DELETED', { + expectedFrom: 'DELETING', + reason: 'Delete completed', + correlationId: job.id ?? undefined, + }); + return { status: 'deleted' }; +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1c60940..c7605c7 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -7,16 +7,24 @@ import { processDnsSyncTick } from './handlers/dns-sync'; import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing'; import { processNodeHealthTick } from './handlers/node-health'; import { processNotificationJob } from './handlers/notifications'; +import { processNotificationDispatchJob } from './handlers/notification-dispatch'; import { processAddonJob } from './handlers/server-addons'; import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups'; import { processLifecycleJob } from './handlers/server-lifecycle'; import { processProvisionServerJob } from './handlers/server-provisioning'; import { processStartQueueTick } from './handlers/start-queue'; import { processWhmcsReconciliationTick, processWhmcsUsagePeriodTick } from './handlers/whmcs-sync'; +import { + processCatalogSyncTick, + processComplianceTick, + processScheduleTick, +} from './handlers/schedules-compliance'; import { startHealthServer } from './health'; import { logger } from './logger'; import { closeRedisConnections } from './redis'; import { + QUEUE_CATALOG_SYNC, + QUEUE_DEAD_LETTER, QUEUE_NOTIFICATIONS, QUEUE_SERVER_ADDONS, QUEUE_SERVER_BACKUPS, @@ -39,6 +47,9 @@ function createQueueWorker( ); if (queueName === QUEUE_NOTIFICATIONS) { + if (job.name === 'dispatch-notification') { + return processNotificationDispatchJob(job); + } return processNotificationJob(job); } @@ -58,6 +69,15 @@ function createQueueWorker( return processLifecycleJob(job); } + if (queueName === QUEUE_CATALOG_SYNC) { + return processCatalogSyncTick(); + } + + if (queueName === QUEUE_DEAD_LETTER) { + logger.warn({ jobId: job.id, data: job.data }, 'Dead-letter job recorded'); + return { recorded: true }; + } + logger.warn({ queue: queueName, jobName: job.name }, 'Unhandled job'); }, { connection }, @@ -117,6 +137,15 @@ async function bootstrap(): Promise { void processWhmcsUsagePeriodTick().catch((error: Error) => { logger.error({ err: error }, 'WHMCS usage period tick failed'); }); + void processScheduleTick().catch((error: Error) => { + logger.error({ err: error }, 'Schedule tick failed'); + }); + void processCatalogSyncTick().catch((error: Error) => { + logger.error({ err: error }, 'Catalog sync tick failed'); + }); + void processComplianceTick().catch((error: Error) => { + logger.error({ err: error }, 'Compliance tick failed'); + }); }, 60_000); const shutdown = async (signal: string): Promise => { diff --git a/apps/worker/src/queues.ts b/apps/worker/src/queues.ts index ad51d7e..bd06f03 100644 --- a/apps/worker/src/queues.ts +++ b/apps/worker/src/queues.ts @@ -3,6 +3,8 @@ export const QUEUE_SERVER_BACKUPS = 'server-backups' as const; export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const; export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const; export const QUEUE_NOTIFICATIONS = 'notifications' as const; +export const QUEUE_CATALOG_SYNC = 'catalog-sync' as const; +export const QUEUE_DEAD_LETTER = 'dead-letter' as const; import { Queue } from 'bullmq'; @@ -14,6 +16,8 @@ export const WORKER_QUEUES = [ QUEUE_SERVER_LIFECYCLE, QUEUE_SERVER_PROVISIONING, QUEUE_NOTIFICATIONS, + QUEUE_CATALOG_SYNC, + QUEUE_DEAD_LETTER, ] as const; export type WorkerQueueName = (typeof WORKER_QUEUES)[number]; @@ -49,3 +53,19 @@ export function getBackupsQueue(): Queue { return backupsQueue; } + +let lifecycleQueue: Queue | null = null; + +export function getLifecycleQueue(): Queue { + if (!lifecycleQueue) { + const config = validateConfig(); + lifecycleQueue = new Queue(QUEUE_SERVER_LIFECYCLE, { + connection: { + url: config.REDIS_URL, + maxRetriesPerRequest: null, + }, + }); + } + + return lifecycleQueue; +} diff --git a/deploy/compose/compose.prod.yml b/deploy/compose/compose.prod.yml new file mode 100644 index 0000000..a309717 --- /dev/null +++ b/deploy/compose/compose.prod.yml @@ -0,0 +1,242 @@ +# HexaHost GameCloud — production Docker Compose stack +# Usage: +# docker network create traefik-network +# cp .env.example .env.prod && edit secrets/domains +# docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d + +name: hexahost-gamecloud-prod + +services: + postgres: + image: postgres:16-alpine + restart: always + env_file: + - ../../.env.prod + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-gamecloud} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB:-gamecloud}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + redis: + image: redis:7-alpine + restart: always + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] + env_file: + - ../../.env.prod + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + minio: + image: minio/minio:RELEASE.2024-12-18T13-15-44Z + restart: always + command: server /data --console-address ":9001" + env_file: + - ../../.env.prod + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + minio-init: + image: minio/mc:RELEASE.2024-11-05T11-14-20Z + restart: "no" + depends_on: + minio: + condition: service_healthy + env_file: + - ../../.env.prod + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $${S3_ACCESS_KEY} $${S3_SECRET_KEY}; + mc mb --ignore-existing local/$${S3_BUCKET:-gamecloud}; + mc anonymous set none local/$${S3_BUCKET:-gamecloud}; + exit 0; + " + environment: + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + S3_BUCKET: ${S3_BUCKET:-gamecloud} + networks: + - gamecloud-internal + + api: + build: + context: ../.. + dockerfile: apps/api/Dockerfile + image: hexahost-gamecloud-api:${GAMECLOUD_IMAGE_TAG:-latest} + restart: always + env_file: + - ../../.env.prod + environment: + NODE_ENV: production + APP_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gamecloud} + REDIS_URL: redis://redis:6379 + S3_ENDPOINT: http://minio:9000 + TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/v1/health/live"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + networks: + - gamecloud-internal + - traefik-network + labels: + - traefik.enable=true + - traefik.docker.network=${TRAEFIK_NETWORK:-traefik-network} + - traefik.http.routers.gamecloud-api.rule=Host(`${API_HOST:-api.example.net}`) + - traefik.http.routers.gamecloud-api.entrypoints=websecure + - traefik.http.routers.gamecloud-api.tls=true + - traefik.http.routers.gamecloud-api.tls.certresolver=letsencrypt + - traefik.http.routers.gamecloud-api.middlewares=gamecloud-api-headers@file,gamecloud-rate-limit@file + - traefik.http.routers.gamecloud-api.service=gamecloud-api + - traefik.http.services.gamecloud-api.loadbalancer.server.port=3001 + - traefik.http.routers.gamecloud-api-http.rule=Host(`${API_HOST:-api.example.net}`) + - traefik.http.routers.gamecloud-api-http.entrypoints=web + - traefik.http.routers.gamecloud-api-http.middlewares=gamecloud-redirect-https@file + logging: + driver: json-file + options: + max-size: "100m" + max-file: "10" + + worker: + build: + context: ../.. + dockerfile: apps/worker/Dockerfile + image: hexahost-gamecloud-worker:${GAMECLOUD_IMAGE_TAG:-latest} + restart: always + env_file: + - ../../.env.prod + environment: + NODE_ENV: production + APP_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gamecloud} + REDIS_URL: redis://redis:6379 + S3_ENDPOINT: http://minio:9000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3002/health"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 60s + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "100m" + max-file: "10" + + web: + build: + context: ../.. + dockerfile: apps/web/Dockerfile + image: hexahost-gamecloud-web:${GAMECLOUD_IMAGE_TAG:-latest} + restart: always + env_file: + - ../../.env.prod + environment: + NODE_ENV: production + API_URL: http://api:3001 + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/de"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + networks: + - gamecloud-internal + - traefik-network + labels: + - traefik.enable=true + - traefik.docker.network=${TRAEFIK_NETWORK:-traefik-network} + - traefik.http.routers.gamecloud-web.rule=Host(`${WEB_HOST:-panel.example.net}`) + - traefik.http.routers.gamecloud-web.entrypoints=websecure + - traefik.http.routers.gamecloud-web.tls=true + - traefik.http.routers.gamecloud-web.tls.certresolver=letsencrypt + - traefik.http.routers.gamecloud-web.middlewares=gamecloud-web-headers@file + - traefik.http.routers.gamecloud-web.service=gamecloud-web + - traefik.http.services.gamecloud-web.loadbalancer.server.port=3000 + - traefik.http.routers.gamecloud-web-http.rule=Host(`${WEB_HOST:-panel.example.net}`) + - traefik.http.routers.gamecloud-web-http.entrypoints=web + - traefik.http.routers.gamecloud-web-http.middlewares=gamecloud-redirect-https@file + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + +volumes: + postgres_data: + name: hexahost-gamecloud-postgres + redis_data: + name: hexahost-gamecloud-redis + minio_data: + name: hexahost-gamecloud-minio + +networks: + gamecloud-internal: + name: hexahost-gamecloud-internal + driver: bridge + traefik-network: + external: true + name: ${TRAEFIK_NETWORK:-traefik-network} diff --git a/deploy/monitoring/grafana/datasources.yml b/deploy/monitoring/grafana/datasources.yml new file mode 100644 index 0000000..e936e4d --- /dev/null +++ b/deploy/monitoring/grafana/datasources.yml @@ -0,0 +1,38 @@ +# Grafana provisioning — HexaHost GameCloud +# Mount to /etc/grafana/provisioning/datasources/datasources.yml + +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: 15s + httpMethod: POST + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + editable: false + jsonData: + maxLines: 1000 + derivedFields: + - datasourceUid: prometheus + matcherRegex: "trace_id=(\\w+)" + name: TraceID + url: "$${__value.raw}" + + - name: Prometheus (external) + type: prometheus + access: proxy + url: http://localhost:9090 + isDefault: false + editable: true + jsonData: + timeInterval: 15s + # Optional — use when Grafana runs outside the compose network diff --git a/deploy/monitoring/loki/loki-config.yml b/deploy/monitoring/loki/loki-config.yml new file mode 100644 index 0000000..4e059d9 --- /dev/null +++ b/deploy/monitoring/loki/loki-config.yml @@ -0,0 +1,56 @@ +# Loki configuration stub — HexaHost GameCloud +# Suitable for single-node control plane deployments. Scale to object storage for HA. + +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: info + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 720h + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + max_streams_per_user: 10000 + reject_old_samples: true + reject_old_samples_max_age: 168h + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem + +analytics: + reporting_enabled: false diff --git a/deploy/traefik/dynamic/gamecloud.yml b/deploy/traefik/dynamic/gamecloud.yml new file mode 100644 index 0000000..f7aadbf --- /dev/null +++ b/deploy/traefik/dynamic/gamecloud.yml @@ -0,0 +1,95 @@ +# HexaHost GameCloud — Traefik dynamic configuration +# Shared middleware and optional file-based routers for the control plane stack. +# Primary routing is defined via Docker labels in deploy/compose/compose.prod.yml. + +http: + middlewares: + gamecloud-redirect-https: + redirectScheme: + scheme: https + permanent: true + + gamecloud-api-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + contentTypeNosniff: true + frameDeny: true + referrerPolicy: strict-origin-when-cross-origin + customRequestHeaders: + X-Forwarded-Proto: https + + gamecloud-web-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + frameDeny: true + referrerPolicy: strict-origin-when-cross-origin + customRequestHeaders: + X-Forwarded-Proto: https + + gamecloud-rate-limit: + rateLimit: + average: 100 + burst: 200 + period: 1s + + routers: + # File-provider routers — useful when Traefik runs outside the compose project + # or when you prefer centralised routing. Disable duplicate Docker labels if you + # rely exclusively on these definitions. + gamecloud-api: + rule: Host(`api.example.net`) + entryPoints: + - websecure + service: gamecloud-api + middlewares: + - gamecloud-api-headers + - gamecloud-rate-limit + tls: + certResolver: letsencrypt + + gamecloud-web: + rule: Host(`panel.example.net`) + entryPoints: + - websecure + service: gamecloud-web + middlewares: + - gamecloud-web-headers + tls: + certResolver: letsencrypt + + services: + gamecloud-api: + loadBalancer: + servers: + - url: http://api:3001 + passHostHeader: true + healthCheck: + path: /api/v1/health/live + interval: 15s + timeout: 5s + + gamecloud-web: + loadBalancer: + servers: + - url: http://web:3000 + passHostHeader: true + healthCheck: + path: /de + interval: 15s + timeout: 5s + +tls: + options: + default: + minVersion: VersionTLS12 + cipherSuites: + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 + - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 diff --git a/deploy/traefik/traefik.yml b/deploy/traefik/traefik.yml new file mode 100644 index 0000000..f5a5185 --- /dev/null +++ b/deploy/traefik/traefik.yml @@ -0,0 +1,59 @@ +# HexaHost GameCloud — Traefik static configuration +# Mount this file read-only when running the Traefik container on the control plane host. + +global: + checkNewVersion: false + sendAnonymousUsage: false + +api: + dashboard: true + insecure: false + +log: + level: INFO + format: json + +accessLog: + format: json + filters: + statusCodes: + - "400-599" + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + permanent: true + websecure: + address: ":443" + http: + tls: + certResolver: letsencrypt + domains: + - main: "${WEB_HOST:-panel.example.net}" + - main: "${API_HOST:-api.example.net}" + +providers: + docker: + endpoint: unix:///var/run/docker.sock + exposedByDefault: false + network: traefik-network + watch: true + file: + directory: /etc/traefik/dynamic + watch: true + +certificatesResolvers: + letsencrypt: + acme: + email: "${ACME_EMAIL:-ops@example.net}" + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: web + +serversTransport: + insecureSkipVerify: false diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f1edc88..e2fcd2f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,8 +1,32 @@ # HexaHost GameCloud — Implementation Status -Last updated: 2026-06-26 +Last updated: 2026-07-05 -## Current phase: WHMCS hardening — mTLS + Product mappings ✅ +## Current phase: MVP complete — hardening & production readiness + +### Sprint 3 (2026-07-05) + +| Area | Status | +|------|--------| +| Members UI (server access tab) | Done | +| Organizations / Support / Abuse APIs | Done | +| Compliance export (S3) + account deletion | Done | +| Cron-parser schedules (API + worker) | Done | +| Global idempotency interceptor | Done | +| Feature flag maintenance_mode | Done | +| WHMCS PHP start/stop/restart/backup | Done | +| Worker catalog-sync + notification dispatch | Done | +| Gap-closure DB migration (40 tables) | Done | +| E2E smoke tests extended | Done | + +### Next: Production hardening + +- Penetration test, integration API IP allowlist +- Client-side backup encryption, Seccomp/AppArmor profiles +- ClamAV upload scanning +- Full E2E regression suite in CI + +## Previous phase: WHMCS hardening — mTLS + Product mappings ✅ ### mTLS integration API diff --git a/docs/MASTERPROMPT_GAP_ANALYSIS.md b/docs/MASTERPROMPT_GAP_ANALYSIS.md new file mode 100644 index 0000000..1f3a517 --- /dev/null +++ b/docs/MASTERPROMPT_GAP_ANALYSIS.md @@ -0,0 +1,86 @@ +# Masterprompt Gap-Analyse — HexaHost GameCloud + +Stand: 2026-07-05 (Sprint 1–3 abgeschlossen) + +Vergleich mit `HexaHost_GameCloud_Cursor_Masterprompt.md`. Legende: ✅ erledigt · 🟡 teilweise · ❌ offen · 🔜 Post-MVP + +--- + +## Zusammenfassung + +| Bereich | Status | +|---------|--------| +| Auth, 2FA, Sessions, CSRF | ✅ | +| Server-Lifecycle, Queue, Multi-Node | ✅ | +| Console, Files, Properties, Worlds, Backups | ✅ | +| Add-ons (Modrinth), Catalog | ✅ | +| Billing / Idle-Stop / Credits | ✅ | +| WHMCS Integration + mTLS + Actions | ✅ | +| Members, Schedules, Players, Software-Reinstall | ✅ | +| Notifications (In-App, E-Mail, Webhook, Discord) | ✅ | +| Organizations, Support, Abuse | ✅ | +| Compliance (Export, Deletion) | ✅ | +| Feature Flags, Idempotency, OIDC, OTel | ✅ | +| Radix UI, E2E, apps/docs | ✅ | +| DB-Migration Gap-Closure | ✅ (SQL vorhanden) | + +--- + +## 30. Abgeschlossene Sprint-Prioritäten + +### Sprint 1 (Gap-Closure) +Login-Fix, CSRF, Admin-UI, öffentliche Seiten, Permissions, Server-State, WHMCS Renew/SSO, Deploy-Stubs, 25 Ops-Docs, Container-Härtung Node-Agent. + +### Sprint 2 (Feature-Completion) +Datei-Upload/Archiv, Spieler-Mutationen, Software-Reinstall, Schedules-API, Notifications-REST, OTel, Playwright, FeatureFlags, OIDC, CurseForge, Radix UI, apps/docs, Idempotency, WHMCS actions API. + +### Sprint 3 (Hardening) +- **Members-UI** (`server-access.tsx`) an Members-API +- **Organizations / Support / Abuse** REST-Module +- **Compliance**: S3-Export, Kontolöschung mit Anonymisierung + Server-Delete-Queue +- **Cron-Parser** (API + Worker) für Schedules +- **Idempotency** global registriert +- **Feature Flag** `maintenance_mode` +- **WHMCS PHP**: Start/Stop/Restart/Backup Buttons + ApiClient +- **Worker**: Catalog-Sync (Modrinth), Notification-Dispatch (E-Mail/Webhook/Discord), Compliance-Tick +- **Migration** `20260705160000_gap_closure_tables` (40 Tabellen) +- **Security-Docs**: backup-encryption, container-hardening +- **Impressum**, erweiterte E2E-Smoke-Tests + +--- + +## 31. Post-MVP / Produktions-Härtung + +| Thema | Status | +|-------|--------| +| Kubernetes, Live-Migration | 🔜 | +| Passkeys, Werbenetzwerke | 🔜 | +| ZFS/Ceph Storage-Adapter | 🔜 | +| Backup Client-Side-Verschlüsselung | 🔜 (Bucket-SSE dokumentiert) | +| Seccomp/AppArmor Profile | 🔜 (Baseline Docker-Härtung ✅) | +| ClamAV Upload-Scan | 🔜 | +| Penetrationstest | 🔜 | +| Integration-API IP-Allowlist | 🔜 | + +--- + +## 32. Lokales Setup + +```bash +cp .env.example .env +pnpm dev:infra +pnpm db:migrate # oder pnpm db:push bei frischer DB +pnpm db:seed +pnpm bootstrap:admin -- --email admin@example.com --password 'Relation123!' +pnpm dev +``` + +Login: `admin@example.com` / `Relation123!` + +--- + +## 33. Pflege-Hinweis + +Bei weiteren Änderungen diese Datei und `docs/IMPLEMENTATION_STATUS.md` aktualisieren. + +*Erstellt: 2026-07-05 · Sprint 3: 2026-07-05* diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..765b0ab --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,51 @@ +# GameCloud API documentation + +The HexaHost GameCloud REST API is documented with OpenAPI (Swagger) and served by the API process. + +## Interactive documentation + +When the API is running: + +| Environment | URL | +|-------------|-----| +| Local development | http://localhost:3001/api/v1/docs | +| Production | https://api.example.net/api/v1/docs | + +Replace `api.example.net` with your `API_HOST` / `API_URL` hostname. + +The OpenAPI JSON spec is available at `/api/v1/docs-json` on the same host. + +## Authentication + +| Audience | Method | +|----------|--------| +| Customer panel / users | Session cookie or bearer token after `POST /api/v1/auth/login` | +| WHMCS integration | HMAC-signed requests — see [WHMCS security](../integrations/whmcs/security.md) | +| Node agent | mTLS + enrollment token on WebSocket | +| Admin | Elevated role on session | + +Public health endpoints (`/api/v1/health/*`) require no authentication. + +## Versioning + +All routes are prefixed with `/api/v1/`. Breaking changes require a new major prefix; WHMCS modules declare compatible `APIVersion` in module metadata. + +## Code location + +- Controllers: `apps/api/src/**/*.controller.ts` +- Swagger setup: `apps/api/src/main.ts` (`SwaggerModule.setup('api/v1/docs', ...)`) +- DTOs: colocated with controllers and in `packages/shared` + +## Generating a static export + +```bash +curl -s http://localhost:3001/api/v1/docs-json -o openapi.json +``` + +Use with codegen tools or import into Postman/Insomnia. + +## Related + +- [Control plane operations](../operations/control-plane.md) +- [WHMCS integration](../integrations/whmcs/installation.md) +- [Threat model](../security/threat-model.md) diff --git a/docs/integrations/whmcs/addons.md b/docs/integrations/whmcs/addons.md new file mode 100644 index 0000000..6ae8bc0 --- /dev/null +++ b/docs/integrations/whmcs/addons.md @@ -0,0 +1,56 @@ +# WHMCS addon products + +Bill optional GameCloud features as WHMCS addon products linked to the main Minecraft service. + +## Supported addon types + +| WHMCS addon | GameCloud feature | Module action | +|-------------|-------------------|---------------| +| Extra backup retention | Extended backup slots | Usage / plan metadata sync | +| Dedicated IP (future) | Reserved port/IP on node | Manual / Phase 10 | +| Priority support | No technical change | Billing only | + +Technical addons call GameCloud APIs during `CreateAccount` / `TerminateAccount` on the addon service or via usage records — see [usage billing](../usage-billing.md). + +## Setup + +**Setup → Products/Services → Product Addons → Create New Addon** + +1. Assign to Minecraft parent products +2. Module: **HexaHost GameCloud** (if addon provisioning implemented) or **None** for pure billing markers +3. Map addon ID in GameCloud addon module **Mappings** (addon tab) when available + +## Linking to parent service + +GameCloud correlates via: + +- `externalServiceId` — parent WHMCS service ID +- `externalAddonId` — addon service ID (usage export) + +Ensure parent service is provisioned before addon activation. + +## Lifecycle + +| WHMCS event | Expected behavior | +|-------------|-------------------| +| Addon ordered | Enable feature flag on server | +| Addon suspended | Disable feature; do not delete data | +| Addon terminated | Revert limits; purge addon-specific data | + +## Metered addons + +For usage-based billing (playtime, storage overage): + +1. GameCloud worker aggregates usage +2. WHMCS polls via addon module or server module metric export +3. WHMCS generates invoices — [usage billing](../usage-billing.md) + +## Reconciliation + +Orphaned addons (parent terminated, addon active) appear in **Reconciliation** dashboard. Run dry-run before live fix. + +## Related + +- [Products](products.md) +- [Usage billing](../usage-billing.md) +- [Reconciliation](../reconciliation.md) diff --git a/docs/integrations/whmcs/configurable-options.md b/docs/integrations/whmcs/configurable-options.md new file mode 100644 index 0000000..cd0312a --- /dev/null +++ b/docs/integrations/whmcs/configurable-options.md @@ -0,0 +1,64 @@ +# WHMCS configurable options + +Map WHMCS configurable options to GameCloud server parameters at provision and upgrade time. + +## Overview + +Configurable options let customers choose RAM tiers, Minecraft versions, or software types at order time. The server module reads selected options during `CreateAccount` and `ChangePackage`. + +**Preferred approach:** Use addon product mappings for plan slugs; use configurable options for customer-visible upgrades that WHMCS invoices. + +## Standard option layout + +| Option name | Type | Maps to | +|-------------|------|---------| +| Plan | Dropdown | GameCloud plan slug (via mapping resolver) | +| Minecraft Version | Dropdown | `minecraftVersion` | +| Software | Dropdown | `softwareFamily` | +| Extra Backup Slot | Quantity | WHMCS addon product — see [addons](addons.md) | + +Option order matters if using fallback config option indices in legacy setups. Mappings table overrides text fields when an active row exists. + +## Dropdown values + +### Software family + +Values must match GameCloud enum exactly: + +`VANILLA`, `PAPER`, `PURPUR`, `FABRIC`, `FORGE`, `NEOFORGE`, `QUILT` + +### Minecraft version + +Use semver strings present in GameCloud catalog. Invalid versions fail provisioning with module log error. + +## Upgrade path + +When a customer upgrades via WHMCS configurable option change: + +1. WHMCS invokes `ChangePackage` +2. Module sends new `planSlug` to GameCloud +3. GameCloud resizes limits; may require server restart + +Communicate restart requirement in WHMCS product description. + +## Validation + +The addon **Mappings** page validates plan slugs before save. Configurable option text is **not** validated until order time — typos cause provisioning failures. + +## Pricing + +WHMCS handles all pricing. GameCloud never stores invoice amounts in WHMCS mode (`BILLING_PROVIDER=whmcs`). + +## Example: RAM upgrade option + +1. WHMCS configurable option group "Upgrades" +2. Option "RAM Tier" with values mapping to plan slugs `starter`, `pro`, `enterprise` +3. Product mapping links WHMCS product + option combination to slug via `ProductMapping.php` resolver + +For complex matrices, use separate WHMCS products instead of many conditional options. + +## Related + +- [Products](products.md) +- [Product mappings](../product-mappings.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/integrations/whmcs/configuration.md b/docs/integrations/whmcs/configuration.md new file mode 100644 index 0000000..9ff19ee --- /dev/null +++ b/docs/integrations/whmcs/configuration.md @@ -0,0 +1,77 @@ +# WHMCS configuration + +Global and per-product settings for the HexaHost GameCloud WHMCS integration. + +## Addon module (global) + +**Setup → Addon Modules → HexaHost GameCloud → Configure** + +| Setting | GameCloud env | Description | +|---------|---------------|-------------| +| GameCloud API URL | `API_URL` | Base URL, e.g. `https://api.example.net` — no trailing slash | +| Integration ID | `WHMCS_INTEGRATION_ID` | Unique installation identifier | +| API Secret | `WHMCS_API_SECRET` | HMAC shared secret (≥ 32 chars) | +| Use mTLS client certificate | `INTEGRATION_MTLS_ENABLED` | Enable client cert — see [mtls](../mtls.md) | +| Client certificate path | — | PEM path readable by PHP | +| Client private key path | — | PEM path, restricted permissions | +| CA bundle path | — | Trust anchor for GameCloud API | +| Auto-reconcile on daily cron | — | Dry-run reconciliation after event poll | +| Event poll batch size | — | Default 50 | + +## GameCloud side + +```env +BILLING_PROVIDER=whmcs +WHMCS_INTEGRATION_ID=prod-whmcs-main +WHMCS_API_SECRET= +WHMCS_WEBHOOK_SECRET= +INTEGRATION_MTLS_ENABLED=true +APP_URL=https://panel.example.net +API_URL=https://api.example.net +``` + +Restart `api` after credential changes. + +## Server module (per product) + +Each WHMCS product using module `hexagamecloud` needs: + +1. **Module Name:** HexaHost GameCloud +2. **Server:** Server entry pointing to API hostname +3. **Config options** or addon mappings — see [products](products.md) and [product mappings](../product-mappings.md) + +### Default config options + +| Option | Purpose | +|--------|---------| +| GameCloud Plan Slug | Fallback plan if no mapping row | +| Minecraft Version | Default version at provision | +| Software Family | VANILLA, PAPER, etc. | + +Prefer **addon mappings** over raw config option text for production. + +## Cron + +WHMCS daily cron invokes: + +- Pending event poll from GameCloud +- Optional reconciliation dry-run + +Ensure WHMCS cron runs at least every 5 minutes for timely suspensions; daily hook handles bulk sync. + +## Reverse proxy + +If WHMCS reaches GameCloud through nginx/Traefik, preserve headers for mTLS fingerprint forwarding. Example: `deploy/nginx/integration-mtls.conf.example`. + +## Staging checklist + +- [ ] Integration ID unique per environment +- [ ] Test order on hidden WHMCS product +- [ ] SSO login to panel works ([sso](../sso.md)) +- [ ] Suspend / unsuspend updates server state within 60s + +## Related + +- [Installation](installation.md) +- [Security](security.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/integrations/whmcs/installation.md b/docs/integrations/whmcs/installation.md new file mode 100644 index 0000000..b28f17a --- /dev/null +++ b/docs/integrations/whmcs/installation.md @@ -0,0 +1,93 @@ +# WHMCS installation + +Install the HexaHost GameCloud provisioning and addon modules on WHMCS 8.x+. + +## Prerequisites + +- WHMCS with SSL (HTTPS admin and client area) +- GameCloud control plane deployed (`deploy/compose/compose.prod.yml`) +- `BILLING_PROVIDER=whmcs` on GameCloud +- Matching credentials prepared (`WHMCS_INTEGRATION_ID`, `WHMCS_API_SECRET`) + +## Package contents + +Source tree: `integrations/whmcs/` + +| Component | WHMCS path | +|-----------|------------| +| Server module | `/modules/servers/hexagamecloud/` | +| Addon module | `/modules/addons/hexagamecloud/` | +| Hooks | `/includes/hooks/hexagamecloud.php` | + +Build a distributable ZIP: + +```bash +cd integrations/whmcs/packaging +./build-package.sh +``` + +## Installation steps + +### 1. Copy server module + +```bash +cp -r integrations/whmcs/modules/servers/hexagamecloud /path/to/whmcs/modules/servers/ +``` + +**Setup → Products/Services → Servers → Create New Server** + +- Type: **HexaHost GameCloud** +- Hostname: `api.example.net` (GameCloud API host only — no path) +- Username / Password: leave empty (credentials are per-product module settings) + +### 2. Copy addon module + +```bash +cp -r integrations/whmcs/modules/addons/hexagamecloud /path/to/whmcs/modules/addons/ +``` + +**Setup → Addon Modules → HexaHost GameCloud → Activate** + +Configure global settings — see [configuration](configuration.md). + +### 3. Install hooks + +```bash +cp integrations/whmcs/hooks/hexagamecloud.php /path/to/whmcs/includes/hooks/ +``` + +Hooks enable daily event polling and optional auto-reconciliation. + +### 4. Configure GameCloud + +On the control plane `.env.prod`: + +```env +BILLING_PROVIDER=whmcs +WHMCS_INTEGRATION_ID=prod-whmcs-main +WHMCS_API_SECRET= +INTEGRATION_MTLS_ENABLED=true +``` + +Restart API after changes. + +### 5. Verify connectivity + +In WHMCS: **Addons → HexaHost GameCloud → Dashboard** + +Expected: green health, catalog plan count, zero pending critical events. + +## File permissions + +WHMCS web user must read module PHP files. Do not make `lib/` or `templates/` web-writable. + +## Multi-environment + +Use separate `WHMCS_INTEGRATION_ID` values for staging and production GameCloud installations. Never point staging WHMCS at production GameCloud. + +## Related + +- [Configuration](configuration.md) +- [Products](products.md) +- [Security](security.md) +- [Addon module](../addon-module.md) diff --git a/docs/integrations/whmcs/products.md b/docs/integrations/whmcs/products.md new file mode 100644 index 0000000..78f9953 --- /dev/null +++ b/docs/integrations/whmcs/products.md @@ -0,0 +1,71 @@ +# WHMCS products + +Creating WHMCS products that provision HexaHost GameCloud Minecraft servers. + +## Product setup + +**Setup → Products/Services → Create New Product** + +| Field | Value | +|-------|-------| +| Product type | Other | +| Module | HexaHost GameCloud (`hexagamecloud`) | +| Server group | Server entry with GameCloud API hostname | + +## Plan mapping + +Each product must resolve to a GameCloud **plan slug** (defines RAM, CPU, backup slots, etc.). + +**Recommended:** Configure via addon **Mappings** tab — [product mappings](../product-mappings.md) + +**Fallback:** Set config option **GameCloud Plan Slug** on the product module settings. + +Validate slugs against live catalog: + +```http +GET /api/v1/catalog/plans +``` + +Only active plans appear in the addon mapping dropdown. + +## Example products + +| WHMCS product name | Plan slug | Target audience | +|--------------------|-----------|-----------------| +| Minecraft Starter | `starter` | 2 GB RAM, 10 slots | +| Minecraft Pro | `pro` | 4 GB RAM, backup included | +| Minecraft Enterprise | `enterprise` | Custom limits | + +Plan slugs are defined in GameCloud admin/catalog — not in WHMCS. + +## Software defaults + +Set per mapping or config options: + +- **Minecraft Version** — e.g. `1.21.1` +- **Software Family** — `VANILLA`, `PAPER`, `PURPUR`, `FABRIC`, `FORGE`, etc. + +Customers may change software via panel after provision if plan allows. + +## Billing cycle + +WHMCS owns billing cycles. GameCloud receives `externalServiceId` and syncs lifecycle (suspend on overdue invoice via WHMCS module actions or events). + +Usage-based overages: see [usage billing](../usage-billing.md). + +## Client area + +The server module renders status and **Login to Panel** (SSO). Domain field is used as display name default (`server-{serviceId}` if empty). + +## Testing + +1. Create admin-only product group +2. Place test order with 100% discount +3. Confirm `CreateAccount` success in module log +4. SSO into panel and verify server RUNNING or STARTING + +## Related + +- [Configurable options](configurable-options.md) +- [Addons](addons.md) +- [Installation](installation.md) diff --git a/docs/integrations/whmcs/security.md b/docs/integrations/whmcs/security.md new file mode 100644 index 0000000..07f188b --- /dev/null +++ b/docs/integrations/whmcs/security.md @@ -0,0 +1,79 @@ +# WHMCS integration security + +Security controls for the API-only link between WHMCS and HexaHost GameCloud. + +## Trust model + +- WHMCS is the **commercial system of record** (payments, invoices) +- GameCloud is the **technical system of record** (servers, nodes) +- Integration is **one-way authenticated API** — no shared database + +## Authentication layers + +### 1. HMAC request signing (required) + +Every request to `/api/v1/integrations/whmcs/*` includes: + +| Header | Purpose | +|--------|---------| +| `X-HGC-Integration-Id` | Installation identifier | +| `X-HGC-Timestamp` | Unix epoch seconds | +| `X-HGC-Signature` | HMAC-SHA256 of canonical request | + +Secret: `WHMCS_API_SECRET` — rotate via [secrets](../../security/secrets.md). + +Clock skew tolerance: ± 300 seconds. Sync NTP on both hosts. + +### 2. mTLS (production recommended) + +When `INTEGRATION_MTLS_ENABLED=true`: + +- Client certificate required from WHMCS host +- Fingerprint registered in GameCloud installation record +- See [mtls](../mtls.md) + +### 3. Network controls + +- Allowlist WHMCS egress IP at firewall or Traefik +- No public exposure of WHMCS admin URL without 2FA +- API only on HTTPS (`api.example.net`) + +## WHMCS hardening + +| Control | Recommendation | +|---------|----------------| +| Admin 2FA | Required for all staff | +| File permissions | Module PHP not writable by web user | +| API Secret storage | WHMCS encrypted settings storage | +| Module logs | Restrict **Utilities → Logs** access | +| Hooks file | `includes/hooks/hexagamecloud.php` owned by root/deploy user | + +## Least privilege + +WHMCS server module credentials are **not** stored in WHMCS server username/password fields — only in addon encrypted settings. GameCloud API keys for customers are separate from integration secret. + +## Audit + +GameCloud logs integration calls with integration ID and action. WHMCS module log redacts secrets. Correlate by timestamp and `externalServiceId`. + +## Incident response + +On suspected secret leak: + +1. Rotate `WHMCS_API_SECRET` on GameCloud first, then WHMCS addon +2. Review module log for anomalous provisioning +3. Run reconciliation dry-run +4. Re-register mTLS fingerprint if cert compromised + +See [incident response](../../operations/incident-response.md). + +## Compliance + +- PCI: WHMCS handles card data; GameCloud never receives PAN +- GDPR: Customer PII flows in `upsertClient` — subject to [data retention](../../security/data-retention.md) + +## Related + +- [Configuration](configuration.md) +- [mTLS](../mtls.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/integrations/whmcs/troubleshooting.md b/docs/integrations/whmcs/troubleshooting.md new file mode 100644 index 0000000..8933091 --- /dev/null +++ b/docs/integrations/whmcs/troubleshooting.md @@ -0,0 +1,101 @@ +# WHMCS troubleshooting + +Common integration failures between WHMCS and HexaHost GameCloud. + +## Diagnostic tools + +| Tool | Location | +|------|----------| +| WHMCS module log | Utilities → Logs → Module Log | +| GameCloud addon dashboard | Addons → HexaHost GameCloud | +| API health | `GET https://api.example.net/api/v1/health/live` | +| OpenAPI | `/api/v1/docs` — [API README](../../api/README.md) | +| Reconciliation | Addon → Reconciliation tab | + +## Authentication errors + +### `Invalid signature` / 401 + +- Confirm `WHMCS_API_SECRET` matches exactly on both sides (no trailing newline) +- Check `WHMCS_INTEGRATION_ID` matches addon **Integration ID** +- Verify server clock (NTP): `X-HGC-Timestamp` skew > 5 min fails +- Ensure **GameCloud API URL** has no trailing slash + +### mTLS / certificate errors + +- `INTEGRATION_MTLS_ENABLED=true` but fingerprint not registered → register in addon **mTLS** tab +- Wrong CA bundle on WHMCS → point to GameCloud CA PEM +- Reverse proxy not forwarding `X-HGC-Client-Cert-Fingerprint` → see [mtls](../mtls.md) + +## Provisioning failures + +### `CreateAccount` returns error string + +1. Read WHMCS module log full message +2. Common causes: + - Invalid plan slug — fix [product mapping](../product-mappings.md) + - Catalog plan inactive + - No eligible game node (`NO_NODE_AVAILABLE`) + - Invalid Minecraft version for software family + +### Server stuck Pending in WHMCS + +- GameCloud provision may have succeeded — check addon dashboard service count +- Run reconciliation dry-run +- Manual SSO to panel to verify server exists + +## Lifecycle sync + +### Suspend / unsuspend not applied + +- WHMCS module action failed silently — check module log +- GameCloud worker backlog — check Redis / worker logs +- Event poll not running — verify WHMCS cron active + +### Terminate left data on node + +- Terminate API may have failed mid-flight — admin force-terminate in panel +- Node offline during terminate — retry when node ONLINE + +## SSO issues + +See dedicated [SSO guide](../sso.md). Typical fixes: + +- `APP_URL` mismatch between GameCloud and expected redirect +- Clock skew +- Service not linked to correct `externalServiceId` + +## Usage billing gaps + +- Usage export cron not running +- Mismatch between WHMCS service ID and GameCloud external ID — reconciliation + +## Network + +```bash +# From WHMCS host +curl -v https://api.example.net/api/v1/health/live +openssl s_client -connect api.example.net:443 -servername api.example.net +``` + +Firewall must allow outbound 443 from WHMCS to GameCloud API. + +## Enable debug logging + +WHMCS: enable module debug in **System Settings → General Settings** (temporarily). + +GameCloud: set `LOG_LEVEL=debug` on API, reproduce, revert to `info`. + +## Escalation data to collect + +- WHMCS service ID and timestamp of failure +- Module log entry (redact secrets) +- GameCloud request ID from API response header if present +- Integration ID and API version + +## Related + +- [Security](security.md) +- [Configuration](configuration.md) +- [Incident response](../../operations/incident-response.md) +- [Reconciliation](../reconciliation.md) diff --git a/docs/integrations/whmcs/upgrades.md b/docs/integrations/whmcs/upgrades.md new file mode 100644 index 0000000..c322128 --- /dev/null +++ b/docs/integrations/whmcs/upgrades.md @@ -0,0 +1,74 @@ +# WHMCS module upgrades + +Upgrade paths for `integrations/whmcs` server and addon modules alongside GameCloud API releases. + +## Version compatibility + +Check release notes for minimum pairs: + +| GameCloud API | Server module | Addon module | +|---------------|---------------|--------------| +| 1.0.x | 1.0.x | 1.0.x | +| 1.1.x | 1.1.x | 1.1.0+ (SQL migrations) | + +Module metadata declares `APIVersion` in `hexagamecloud_MetaData()`. + +## Upgrade order + +1. **GameCloud API** — deploy new control plane ([upgrades](../../operations/upgrades.md)) +2. **Database migrations** — addon SQL in `modules/addons/hexagamecloud/sql/upgrade-*.sql` +3. **WHMCS addon module** — copy files, run upgrade hook +4. **WHMCS server module** — copy files (usually no DB change) +5. **Verify** — dashboard health, test suspend/unsuspend + +Never upgrade WHMCS modules before the API when release notes specify API-first ordering. + +## Addon SQL migrations + +WHMCS runs `hexagamecloud_upgrade()` on version bump. Manual apply if needed: + +```bash +mysql whmcs < integrations/whmcs/modules/addons/hexagamecloud/sql/upgrade-1.1.0.sql +``` + +Back up WHMCS database before upgrade. + +## File deployment + +```bash +# From packaging script output or git +cp -r integrations/whmcs/modules/addons/hexagamecloud /path/to/whmcs/modules/addons/ +cp -r integrations/whmcs/modules/servers/hexagamecloud /path/to/whmcs/modules/servers/ +cp integrations/whmcs/hooks/hexagamecloud.php /path/to/whmcs/includes/hooks/ +``` + +Clear WHMCS template cache if admin UI looks stale. + +## Zero-downtime considerations + +- Running game servers unaffected by module file update +- Brief API restart during GameCloud upgrade may delay WHMCS module calls — retry logic in `ApiClient.php` handles transient 502 +- Place WHMCS in maintenance mode only if API breaking change requires it + +## Rollback + +1. Restore previous module files from backup +2. Roll back GameCloud images if API incompatible +3. Restore WHMCS DB if SQL migration ran + +Product mappings table is forward-compatible unless release notes say otherwise. + +## Packaging + +```bash +cd integrations/whmcs/packaging +./build-package.sh +``` + +Distribute ZIP to production WHMCS via change-controlled deploy. + +## Related + +- [Installation](installation.md) +- [Troubleshooting](troubleshooting.md) +- [Control plane upgrades](../../operations/upgrades.md) diff --git a/docs/operations/backup-restore.md b/docs/operations/backup-restore.md new file mode 100644 index 0000000..f804ca0 --- /dev/null +++ b/docs/operations/backup-restore.md @@ -0,0 +1,108 @@ +# Backup and restore + +This runbook covers backing up and restoring HexaHost GameCloud control plane data. Game server world data is backed up separately via the in-product backup feature (stored in object storage). + +## Scope + +| Component | Method | Frequency | +|-----------|--------|-----------| +| PostgreSQL | `pg_dump` + WAL archiving | Daily full; continuous WAL (recommended) | +| Redis | RDB snapshot (`BGSAVE`) | Hourly if non-transient keys matter | +| Object storage (MinIO/S3) | Replication or `mc mirror` | Continuous or daily | +| Traefik ACME | Copy `acme.json` | After each cert issue | +| `.env.prod` / secrets | Encrypted off-site vault | On change | +| WHMCS mappings | DB export of `mod_hexagamecloud_*` | Weekly | + +## PostgreSQL backup + +### Logical dump (single host) + +```bash +docker exec hexahost-gamecloud-prod-postgres-1 \ + pg_dump -U gamecloud -Fc gamecloud > "gamecloud-$(date +%F).dump" +``` + +Store dumps encrypted off-site. Retain at least 30 daily backups for production. + +### Restore from dump + +1. Stop API and worker to prevent writes: + + ```bash + docker compose -f deploy/compose/compose.prod.yml stop api worker + ``` + +2. Drop and recreate database (destructive): + + ```bash + docker exec -i hexahost-gamecloud-prod-postgres-1 \ + psql -U gamecloud -c "DROP DATABASE gamecloud WITH (FORCE);" + docker exec -i hexahost-gamecloud-prod-postgres-1 \ + psql -U gamecloud -c "CREATE DATABASE gamecloud OWNER gamecloud;" + ``` + +3. Restore: + + ```bash + cat gamecloud-2026-07-01.dump | docker exec -i hexahost-gamecloud-prod-postgres-1 \ + pg_restore -U gamecloud -d gamecloud --no-owner --role=gamecloud + ``` + +4. Start services and verify: + + ```bash + docker compose -f deploy/compose/compose.prod.yml start api worker + curl -s https://api.example.net/api/v1/health/live + ``` + +### Point-in-time recovery (PITR) + +For managed PostgreSQL or self-hosted WAL archiving, enable `archive_mode` and restore to a timestamp before the incident. Document your WAL retention in [disaster-recovery](disaster-recovery.md). + +## Redis backup + +Redis holds job queues and ephemeral cache. For queue durability during maintenance: + +```bash +docker exec hexahost-gamecloud-prod-redis-1 redis-cli BGSAVE +docker cp hexahost-gamecloud-prod-redis-1:/data/dump.rdb ./redis-$(date +%F).rdb +``` + +Restore by stopping Redis, replacing `dump.rdb`, and restarting. Expect in-flight jobs to be reprocessed or dead-lettered. + +## Object storage backup + +```bash +mc alias set prod http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" +mc mirror prod/gamecloud /backup/gamecloud-$(date +%F)/ +``` + +Verify checksums on a sample of backup objects (`backups/*/manifest.json`). + +## Game server backups (tenant data) + +Customer-initiated backups are stored under `s3://gamecloud/backups/{serverId}/`. Restore through the panel or admin API — not via PostgreSQL restore alone. + +## Pre-restore checklist + +- [ ] Incident ticket with restore target time documented +- [ ] WHMCS placed in maintenance mode if billing sync is active +- [ ] Node agents will reconnect automatically after API restore +- [ ] DNS records remain valid (no restore needed for RFC2136 provider state in DB) + +## Verification after restore + +1. Admin login and random server detail page +2. WHMCS addon dashboard health (`Addons → HexaHost GameCloud`) +3. Worker queue processing (`failed` jobs near zero) +4. Sample backup download from object storage + +## Automation + +Integrate dumps with your existing backup tool (restic, Borg, Velero). Ansible playbooks in `deploy/ansible/` provision hosts; schedule backups via cron or systemd timers on `cp-db` or the compose host. + +## Related + +- [Disaster recovery](disaster-recovery.md) +- [Object storage](object-storage.md) +- [Data retention](../security/data-retention.md) diff --git a/docs/operations/disaster-recovery.md b/docs/operations/disaster-recovery.md new file mode 100644 index 0000000..d12106a --- /dev/null +++ b/docs/operations/disaster-recovery.md @@ -0,0 +1,80 @@ +# Disaster recovery + +Recovery objectives and procedures when a HexaHost GameCloud control plane or game node is lost. + +## Recovery objectives (default targets) + +| Tier | RPO | RTO | Notes | +|------|-----|-----|-------| +| Control plane (API, DB) | 1 h | 4 h | With WAL + hourly Redis snapshot | +| Object storage (backups) | 15 min | 4 h | With cross-region replication | +| Single game node | 0 (running servers) | 30 min | Reschedule + DNS unchanged if edge IP stable | +| Full region loss | 24 h | 24 h | Requires warm standby or restore from off-site | + +Adjust these in your internal SLA and document approved deviations. + +## Failure scenarios + +### Control plane host lost + +1. Provision replacement via `deploy/ansible/control-plane.yml` +2. Restore PostgreSQL from latest dump + WAL ([backup-restore](backup-restore.md)) +3. Restore MinIO data or repoint `S3_*` to replica bucket +4. Deploy compose: `deploy/compose/compose.prod.yml` +5. Restore Traefik `acme.json` or re-issue certificates +6. Verify WHMCS integration (HMAC secret unchanged) + +### Database corruption + +1. Stop writes (`api`, `worker`) +2. Restore to last known-good PITR timestamp +3. Run WHMCS reconciliation dry-run from addon module +4. Review audit log gap for the corruption window + +### Object storage unavailable + +- API continues serving metadata; backup restore and file uploads fail +- Fail over to replica endpoint or restore MinIO volume from snapshot +- Workers retry S3 operations with exponential backoff + +### All game nodes offline + +- Customers cannot start servers; running containers on nodes may continue until stopped +- Edge gateway returns connection errors for join-to-start +- Restore nodes in priority order; scheduler assigns new provisions when nodes return + +### WHMCS host lost + +GameCloud remains authoritative for technical state. Reinstall WHMCS module from `integrations/whmcs/`, restore WHMCS database, re-import product mappings from backup export. + +## Standby strategy (recommended) + +| Component | Standby approach | +|-----------|------------------| +| PostgreSQL | Streaming replica or managed HA | +| Redis | Sentinel or managed Redis | +| S3 | Cross-region replication | +| Control plane VMs | IaC templates in `deploy/ansible/` | +| Traefik certs | Shared `acme.json` on persistent volume | + +## Communication + +During DR: + +1. Status page update (if applicable) +2. Internal war room with roles: coordinator, DB, network, WHMCS +3. Customer comms after RTO estimate is known — WHMCS can send mass email + +## DR drill checklist (quarterly) + +- [ ] Restore PostgreSQL dump to staging environment +- [ ] Restore sample backup from object storage to test server +- [ ] Fail over DNS to standby edge IP (dry run) +- [ ] WHMCS provisioning test order on staging +- [ ] Document actual RPO/RTO vs targets + +## Related + +- [Backup and restore](backup-restore.md) +- [Server migration](server-migration.md) +- [Incident response](incident-response.md) diff --git a/docs/operations/incident-response.md b/docs/operations/incident-response.md new file mode 100644 index 0000000..e16d0aa --- /dev/null +++ b/docs/operations/incident-response.md @@ -0,0 +1,84 @@ +# Incident response + +Operational playbook for security and availability incidents affecting HexaHost GameCloud. + +## Severity levels + +| Level | Examples | Response time | Examples | +|-------|----------|---------------|----------| +| SEV-1 | API down, data breach suspected, all nodes offline | 15 min | Full platform outage | +| SEV-2 | Single node down, WHMCS provision failures, DNS sync broken | 1 h | Partial customer impact | +| SEV-3 | Elevatoration, non-critical bug, one customer server stuck | Next business day | Limited blast radius | + +## Initial response (all severities) + +1. **Acknowledge** — Assign incident commander and scribe +2. **Triage** — Customer-facing vs internal-only impact +3. **Communicate** — Internal channel + status page if SEV-1/2 +4. **Preserve evidence** — Do not reboot hosts before log capture if security-related + +## Common incidents + +### API unavailable (502/503) + +```bash +docker compose -f deploy/compose/compose.prod.yml ps +docker compose -f deploy/compose/compose.prod.yml logs --tail=200 api +curl -v https://api.example.net/api/v1/health/live +``` + +Check Traefik routing ([traefik](traefik.md)), PostgreSQL connectivity, disk full. + +### Worker queue backlog + +Inspect Redis queue depth and failed jobs in logs. Scale worker container or restart after fixing root cause (DNS provider down, S3 errors). + +### Node heartbeat stale + +Nodes not seen > 90s trigger alerts. Verify node-agent systemd status, outbound firewall to `wss://api.example.net/api/v1/nodes/ws`, certificate expiry. + +### Suspected credential leak + +1. Rotate affected secrets immediately ([secrets](../security/secrets.md)): + - `SESSION_SECRET` (invalidates all sessions) + - `WHMCS_API_SECRET` per installation + - `NODE_TOKEN` for compromised node + - `S3_ACCESS_KEY` / database password +2. Review audit logs and WHMCS module call logs +3. Enable mTLS if not already active for WHMCS integration + +### Tenant isolation concern (SEV-1) + +1. Disable affected API endpoints if exploit is active +2. Capture request IDs from audit log +3. Engage security lead; reference [threat model](../security/threat-model.md) T21 + +## WHMCS-specific + +Provisioning failures often appear in WHMCS **Utilities → Logs → Module Log**. Cross-check GameCloud API logs with `X-HGC-Integration-Id` header. + +Run reconciliation dry-run before live fixes: [reconciliation](../integrations/whmcs/reconciliation.md). + +## Post-incident + +Within 5 business days: + +- [ ] Timeline document (UTC) +- [ ] Root cause (5 whys) +- [ ] Action items with owners +- [ ] Update runbooks if gaps found + +## Contacts and escalation + +Document internally: + +- On-call rotation +- WHMCS admin contact +- DNS provider support +- Hosting provider (game nodes) + +## Related + +- [Disaster recovery](disaster-recovery.md) +- [Threat model](../security/threat-model.md) +- [WHMCS troubleshooting](../integrations/whmcs/troubleshooting.md) diff --git a/docs/operations/node-drain.md b/docs/operations/node-drain.md new file mode 100644 index 0000000..0d688e9 --- /dev/null +++ b/docs/operations/node-drain.md @@ -0,0 +1,101 @@ +# Game node drain + +Gracefully remove a game node from scheduling before maintenance, decommissioning, or migration. + +## When to drain + +- OS kernel / Docker upgrade on the node +- Hardware maintenance or rack move +- Shrinking cluster capacity +- Replacing a node with new hardware ([server migration](server-migration.md)) + +Do **not** drain the last node in a region if active servers have no migration target. + +## Procedure + +### 1. Set node to DRAINING + +Via admin API: + +```http +PATCH /api/v1/admin/nodes/{nodeId} +Authorization: Bearer +Content-Type: application/json + +{ "status": "DRAINING" } +``` + +The scheduler stops placing new servers on this node. Existing running servers continue until stopped by users or idle shutdown. + +### 2. Wait for natural shutdown (optional) + +If maintenance window allows, wait for idle shutdown policy to stop inactive servers. Monitor: + +```sql +SELECT id, status, "nodeId" FROM "GameServer" WHERE "nodeId" = '' AND status = 'RUNNING'; +``` + +### 3. Migrate or stop remaining servers + +For each running server: + +- **Preferred:** Customer stops server via panel; reprovision on another node on next start (scheduler picks eligible node) +- **Admin:** Force stop via admin API, then start — triggers reschedule if original node is DRAINING +- **Maintenance:** Communicate window to affected customers + +### 4. Confirm empty node + +```sql +SELECT COUNT(*) FROM "GameServer" WHERE "nodeId" = '' AND status IN ('RUNNING', 'STARTING'); +``` + +Expected: `0` before maintenance on Docker host. + +### 5. Stop node-agent + +```bash +sudo systemctl stop hgc-node-agent +``` + +Configured in `deploy/systemd/node-agent.service`. + +### 6. Perform maintenance + +Apply updates via `deploy/ansible/game-node.yml` or manual steps. See [game node operations](game-node.md). + +### 7. Return to service or decommission + +**Return:** + +```http +PATCH /api/v1/admin/nodes/{nodeId} +{ "status": "ONLINE" } +``` + +```bash +sudo systemctl start hgc-node-agent +``` + +**Decommission:** + +1. Revoke enrollment token in control plane +2. Remove `GameNode` row or mark `OFFLINE` permanently +3. Wipe `/var/lib/hgc-node` data if disks are reused + +## Edge cases + +| Situation | Action | +|-----------|--------| +| Server stuck STARTING during drain | Admin stop + investigate node-agent logs | +| Port range exhausted on other nodes | Add node or expand range before drain | +| Node loses connectivity mid-drain | Servers marked UNKNOWN; reconcile when agent returns | + +## Automation + +Future: automated drain API with `--wait-empty` timeout. Until then, use SQL + admin API as above. + +## Related + +- [Game node operations](game-node.md) +- [Server migration](server-migration.md) +- [Upgrades](upgrades.md) diff --git a/docs/operations/object-storage.md b/docs/operations/object-storage.md new file mode 100644 index 0000000..0c75c8b --- /dev/null +++ b/docs/operations/object-storage.md @@ -0,0 +1,90 @@ +# Object storage operations + +HexaHost GameCloud stores backups, world exports, mod archives, and other large blobs in S3-compatible object storage. Development uses MinIO via `deploy/compose/compose.dev.yml`; production uses MinIO or an external S3 provider. + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `S3_ENDPOINT` | Internal URL, e.g. `http://minio:9000` in compose | +| `S3_REGION` | Region identifier (`us-east-1` for MinIO) | +| `S3_BUCKET` | Primary bucket (default `gamecloud`) | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | Credentials — rotate regularly | +| `S3_FORCE_PATH_STYLE` | `true` for MinIO; `false` for AWS S3 | + +## Production (MinIO in compose) + +The production stack in `deploy/compose/compose.prod.yml` runs MinIO on the internal network only — no public ports. API and worker reach it at `http://minio:9000`. + +Initial bucket creation is handled by `minio-init` on first deploy: + +```bash +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up minio-init +``` + +### Admin console access + +Do not expose the MinIO console to the public internet. Options: + +- SSH tunnel: `ssh -L 9001:127.0.0.1:9001 cp-01` +- VPN-only route to the control plane management network +- Separate MinIO deployment with IAM and audit logging + +## External S3 (AWS, Wasabi, Hetzner Object Storage) + +Point `S3_ENDPOINT` at the provider URL and set `S3_FORCE_PATH_STYLE=false` where appropriate. Create the bucket manually and apply a bucket policy that: + +- Denies public read/write +- Allows only the GameCloud service principal (access key) +- Enables versioning for backup objects +- Optionally enforces SSE-S3 or SSE-KMS + +Remove the `minio` and `minio-init` services from compose when using external storage. + +## Object layout + +| Prefix | Content | Retention | +|--------|---------|-----------| +| `backups/{serverId}/` | Scheduled server backups | Per plan + [data retention policy](../security/data-retention.md) | +| `worlds/{serverId}/` | World export ZIPs | 7 days after download link expiry | +| `uploads/{serverId}/` | User file uploads (staging) | Cleaned after successful install | +| `catalog/` | Cached mod/plugin artifacts | Managed by catalog sync jobs | + +## Health and monitoring + +MinIO health endpoint: `GET /minio/health/live` + +Monitor: + +- Bucket size growth (per tenant and total) +- Failed PUT/GET rates from API logs +- Disk usage on MinIO volume `hexahost-gamecloud-minio` + +## Backup of the storage layer + +For self-hosted MinIO: + +1. Enable bucket versioning +2. Replicate to a second bucket or provider (MinIO site replication or `mc mirror`) +3. Include object storage in [backup-restore](backup-restore.md) runbooks + +For external S3, rely on provider replication and lifecycle rules; document RPO/RTO in [disaster-recovery](disaster-recovery.md). + +## Troubleshooting + +### `NoSuchBucket` + +Run `minio-init` or create the bucket manually with the configured `S3_BUCKET` name. + +### Signature errors + +Verify clock sync (NTP) on API/worker hosts. Check `S3_FORCE_PATH_STYLE` matches the provider. + +### Slow backup uploads + +Check network between game nodes (backup source) and storage. Large backups stream from node-agent → API → S3; consider dedicated storage nodes for high-volume deployments. + +## Related + +- [Backup and restore](backup-restore.md) +- [Secrets management](../security/secrets.md) diff --git a/docs/operations/server-migration.md b/docs/operations/server-migration.md new file mode 100644 index 0000000..d1dffd1 --- /dev/null +++ b/docs/operations/server-migration.md @@ -0,0 +1,79 @@ +# Server and host migration + +Moving HexaHost GameCloud workloads between hosts: control plane relocation, game node replacement, and WHMCS host migration. + +## Control plane migration + +Move from `cp-01-old` to `cp-01-new`: + +1. **Prepare new host** — `ansible-playbook -i inventories/production deploy/ansible/control-plane.yml` +2. **Stop writes on old host** — `docker compose stop api worker` +3. **Backup** — PostgreSQL dump, Redis RDB, MinIO mirror, `/opt/hexahost-gamecloud/.env.prod`, Traefik `acme.json` +4. **Transfer** — Secure copy to new host (rsync over SSH, encrypted archive) +5. **Restore** — Follow [backup-restore](backup-restore.md) on new host +6. **Update DNS** — `panel.example.net` and `api.example.net` A/AAAA records to new IP +7. **Start stack** — `docker compose -f deploy/compose/compose.prod.yml up -d` +8. **Verify** — Health checks, WHMCS addon dashboard, node WebSocket reconnections +9. **Decommission old host** — Secure wipe after 72h parallel run (optional) + +WHMCS requires no URL change if hostnames are unchanged. Update `GameCloud API URL` in addon settings only if `api.example.net` changes. + +## Game node migration + +Replace `node-01` with `node-02` while preserving customer servers: + +### Add replacement node first + +1. Create `GameNode` in admin API +2. Deploy agent: `deploy/ansible/game-node.yml` +3. Confirm `ONLINE` status + +### Drain old node + +Follow [node-drain](node-drain.md). Running servers must stop and start on the new node. + +### Data considerations + +- World data lives in Docker volumes on the node — **not** automatically migrated +- Customers should create a backup before migration window +- Admin can trigger backup via API, then restore after server starts on new node + +### DNS + +Join hostnames point to the **edge gateway**, not individual nodes. No DNS change required for node migration unless edge IP changes. + +## Edge gateway migration + +If `EDGE_PUBLIC_HOST` changes: + +1. Update `RFC2136` records or manual DNS for `*.play.example.net` +2. Deploy edge gateway on new IP +3. Worker DNS sync reconciles `server_dns_records` within 60s + +## WHMCS host migration + +1. Export WHMCS database and `mod_hexagamecloud_*` tables +2. Copy `integrations/whmcs/modules/` to new WHMCS install +3. Reconfigure addon: Integration ID, API Secret (unchanged on GameCloud side) +4. Run reconciliation dry-run: **Addons → HexaHost GameCloud → Reconciliation** +5. Re-register mTLS fingerprint if client certificate changes + +See [WHMCS installation](../integrations/whmcs/installation.md). + +## MinIO / S3 migration + +```bash +mc mirror old/gamecloud new/gamecloud +``` + +Update `S3_ENDPOINT` and credentials in `.env.prod`, restart API and worker. + +## Rollback + +Keep old host powered but isolated for 72 hours. Rollback = revert DNS + restart old compose stack from pre-migration backup. + +## Related + +- [Backup and restore](backup-restore.md) +- [Disaster recovery](disaster-recovery.md) +- [Node drain](node-drain.md) diff --git a/docs/operations/traefik.md b/docs/operations/traefik.md new file mode 100644 index 0000000..369f4bd --- /dev/null +++ b/docs/operations/traefik.md @@ -0,0 +1,96 @@ +# Traefik operations + +HexaHost GameCloud exposes the customer panel (`web`) and REST API (`api`) through Traefik on the control plane host. Game nodes and the edge gateway are **not** routed through this stack. + +## Layout + +| Path | Purpose | +|------|---------| +| `deploy/traefik/traefik.yml` | Static config — entry points, ACME, providers | +| `deploy/traefik/dynamic/gamecloud.yml` | Middleware, TLS options, optional file routers | +| `deploy/compose/compose.prod.yml` | Docker labels for primary routing | + +## Prerequisites + +```bash +docker network create traefik-network +``` + +Set in `.env.prod`: + +```env +TRAEFIK_NETWORK=traefik-network +WEB_HOST=panel.example.net +API_HOST=api.example.net +ACME_EMAIL=ops@example.net +APP_URL=https://panel.example.net +API_URL=https://api.example.net +TRUSTED_PROXY_COUNT=1 +``` + +## Run Traefik + +Example standalone container (adjust paths): + +```bash +docker run -d \ + --name traefik \ + --restart always \ + -p 80:80 -p 443:443 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /opt/hexahost-gamecloud/deploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro \ + -v /opt/hexahost-gamecloud/deploy/traefik/dynamic:/etc/traefik/dynamic:ro \ + -v /opt/hexahost-gamecloud/letsencrypt:/letsencrypt \ + --network traefik-network \ + traefik:v3.2 +``` + +Then start the application stack: + +```bash +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d +``` + +## Routing + +| Host | Service | Health check | +|------|---------|--------------| +| `panel.example.net` | `web:3000` | `GET /de` | +| `api.example.net` | `api:3001` | `GET /api/v1/health/live` | + +HTTP on port 80 redirects to HTTPS. API requests receive rate limiting (`100 req/s` average, burst `200`). + +## WHMCS integration mTLS + +WHMCS integration traffic uses the same `api.example.net` host. When `INTEGRATION_MTLS_ENABLED=true`, terminate client certificates at Traefik or nginx and forward `X-HGC-Client-Cert-Fingerprint`. See [WHMCS mTLS](../integrations/whmcs/mtls.md) and `deploy/nginx/integration-mtls.conf.example`. + +## Certificate renewal + +Let's Encrypt certificates are stored in `/letsencrypt/acme.json`. Back up this file before host migrations. Traefik renews automatically; monitor expiry via the Traefik dashboard or Prometheus metrics. + +## Troubleshooting + +### 502 Bad Gateway + +1. Confirm `api` / `web` containers are healthy: `docker compose ps` +2. Verify both services attach to `traefik-network` +3. Check label `traefik.docker.network` matches the external network name + +### Certificate errors + +1. Ensure ports 80 and 443 are reachable from the internet for HTTP-01 challenge +2. Confirm `ACME_EMAIL` is valid +3. Inspect Traefik logs: `docker logs traefik 2>&1 | jq .` + +### Wrong client IP in audit logs + +Increase `TRUSTED_PROXY_COUNT` on the API to match the number of reverse-proxy hops. + +## Ansible + +Control plane provisioning is described in `deploy/ansible/control-plane.yml`. After OS setup, copy Traefik and compose files to `/opt/hexahost-gamecloud` and enable systemd or compose restart policies. + +## Related + +- [Control plane operations](control-plane.md) +- [Installation (development)](installation.md) diff --git a/docs/operations/upgrades.md b/docs/operations/upgrades.md new file mode 100644 index 0000000..b0062c4 --- /dev/null +++ b/docs/operations/upgrades.md @@ -0,0 +1,86 @@ +# Control plane upgrades + +Rolling upgrades for HexaHost GameCloud API, worker, web, and infrastructure containers on the control plane. + +## Version pinning + +Set in `.env.prod`: + +```env +GAMECLOUD_IMAGE_TAG=1.2.0 +``` + +Build and tag images in CI, or pull from your registry. Never deploy `:latest` in production without a rollback plan. + +## Pre-upgrade checklist + +- [ ] Read release notes and migration requirements (`docs/IMPLEMENTATION_STATUS.md`) +- [ ] Fresh PostgreSQL backup ([backup-restore](backup-restore.md)) +- [ ] WHMCS addon/server module compatibility verified +- [ ] Maintenance window communicated if API downtime expected +- [ ] Staging deploy completed successfully + +## Standard rolling upgrade + +```bash +cd /opt/hexahost-gamecloud + +# Pull/build new images +export GAMECLOUD_IMAGE_TAG=1.2.0 +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod pull +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod build + +# Run database migrations (if applicable — via API container one-shot) +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod run --rm api \ + pnpm --filter @hexahost/api prisma migrate deploy + +# Recreate app containers one at a time +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d --no-deps worker +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d --no-deps api +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d --no-deps web +``` + +## Infrastructure upgrades + +| Service | Notes | +|---------|-------| +| PostgreSQL | Major version requires `pg_upgrade` or dump/restore — plan maintenance window | +| Redis | Minor upgrades usually safe; snapshot before upgrade | +| MinIO | Follow MinIO release notes; backup volume first | +| Traefik | Test dynamic config with `traefik validate` equivalent (dry-run container) | + +## Zero-downtime considerations + +- Run at least two API instances behind Traefik for true zero-downtime (single-node compose has brief API restart) +- Worker: only one active consumer per-correct for some jobs; use graceful shutdown (SIGTERM) before replace +- Web: Next.js standalone reload causes short 502; upgrade during low traffic + +## Rollback + +```bash +export GAMECLOUD_IMAGE_TAG=1.1.0 +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d api worker web +``` + +If migrations ran forward-only, restore PostgreSQL from pre-upgrade dump. + +## WHMCS module upgrades + +See [WHMCS upgrades](../integrations/whmcs/upgrades.md). Upgrade GameCloud API before WHMCS server module when release notes specify API-first ordering. + +## Post-upgrade verification + +```bash +curl -sf https://api.example.net/api/v1/health/live +curl -sf https://panel.example.net/de +``` + +- Admin panel login +- WHMCS test suspend/unsuspend on staging service +- Worker queue depth normal +- Node heartbeats within 30s + +## Related + +- [Control plane operations](control-plane.md) +- [Node drain](node-drain.md) — upgrade game nodes separately diff --git a/docs/security/backup-encryption.md b/docs/security/backup-encryption.md new file mode 100644 index 0000000..658bfc7 --- /dev/null +++ b/docs/security/backup-encryption.md @@ -0,0 +1,9 @@ +# Backup Encryption at Rest + +GameCloud backups are stored in S3-compatible object storage. Production deployments should enable: + +1. **Bucket SSE** — Server-side encryption (AES-256) on the backup bucket +2. **Application envelope encryption** (roadmap) — Per-backup data keys wrapped with `ENCRYPTION_KEY` +3. **Key rotation** — Documented in `docs/security/secrets.md` + +Current MVP stores backups without client-side encryption. Enable bucket policies before production. diff --git a/docs/security/container-hardening.md b/docs/security/container-hardening.md new file mode 100644 index 0000000..b429612 --- /dev/null +++ b/docs/security/container-hardening.md @@ -0,0 +1,14 @@ +# Container Hardening + +The node-agent applies baseline Docker hardening: + +- `cap_drop: ALL` with minimal adds +- `security_opt: no-new-privileges` +- PID limits and log rotation + +## Roadmap (Post-MVP) + +- **Seccomp** — Custom profile per software family under `deploy/seccomp/` +- **AppArmor** — Node-level profiles for game server containers + +See `apps/node-agent/internal/docker/client.go` for current defaults. diff --git a/docs/security/container-isolation.md b/docs/security/container-isolation.md new file mode 100644 index 0000000..0f52cf0 --- /dev/null +++ b/docs/security/container-isolation.md @@ -0,0 +1,71 @@ +# Container isolation + +How HexaHost GameCloud isolates Minecraft server workloads on game nodes. + +## Model + +Each customer server runs as a **non-privileged Docker container** on a dedicated game node. The node-agent creates and manages containers; customers never receive Docker socket access. + +``` +Control plane (trusted) ──mTLS──► Node agent (trusted) ──► Game container (untrusted) +``` + +## Container hardening (target profile) + +| Control | Setting | +|---------|---------| +| User | Non-root UID inside container | +| Privileges | `--privileged=false` | +| Capabilities | Drop ALL, add only required (none for vanilla Java) | +| seccomp | Docker default or custom profile (Phase 2) | +| AppArmor | Docker default profile | +| Network | Bridge network per server; no `host` network mode | +| Read-only root | Where compatible with selected software family | +| Memory / CPU | cgroup limits from plan slug | +| PIDs limit | Prevents fork bombs | + +## Network isolation + +- Game containers reach the internet for Minecraft auth and mod downloads only through controlled egress +- No route to control plane PostgreSQL, Redis, or internal APIs +- Node-agent management port (`9100`) bound to localhost or management VLAN only + +See threat T05 and T12 in [threat model](threat-model.md). + +## Storage isolation + +- Per-server data directory on node disk: `/var/lib/hgc-node/servers/{serverId}/` +- Path traversal prevented server-side; symlinks rejected on archive extract +- Backups uploaded to object storage — not readable by other tenants + +## Image policy + +- Only catalog-approved runtime images (digest-pinned) +- Customers cannot push custom images +- Periodic image vulnerability scan in CI (Phase 9) + +## Multi-tenant on one node + +The scheduler enforces RAM and server count limits per node. There is no shared filesystem between server containers except the read-only image layers. + +## Operator responsibilities + +| Task | Frequency | +|------|-----------| +| Docker security updates on nodes | Monthly | +| Review AppArmor/seccomp profiles after Minecraft/Java major bumps | Per release | +| Audit `docker ps` for unexpected privileged containers | Weekly | + +Bootstrap hardening via `deploy/ansible/game-node.yml`. + +## Verification + +- Attempt cross-container network scan from test server (should fail to reach agent port) +- Confirm `docker inspect` shows `Privileged: false` +- Penetration test before production go-live + +## Related + +- [Threat model](threat-model.md) +- [Game node operations](../operations/game-node.md) +- [Secrets](secrets.md) diff --git a/docs/security/data-retention.md b/docs/security/data-retention.md new file mode 100644 index 0000000..bcb6664 --- /dev/null +++ b/docs/security/data-retention.md @@ -0,0 +1,66 @@ +# Data retention + +Retention periods and deletion procedures for HexaHost GameCloud customer and operational data. + +## Policy summary + +| Data type | Retention | Deletion trigger | +|-----------|-----------|------------------| +| Account profile | Life of account + 90 days | Account deletion request | +| Game server world (live) | Life of service | Terminate in WHMCS / admin | +| Automated backups | Per plan (7–30 days default) | Lifecycle job + S3 expiry | +| Manual backup downloads | Link valid 24 h; object 7 days | Worker cleanup | +| Audit log | 24 months | Scheduled purge job | +| Application logs (Loki) | 30 days (`720h` in loki-config) | Loki compactor | +| Session tokens | 7 days idle / 30 days max | Redis TTL | +| WHMCS sync events | 90 days acknowledged | Addon retention SQL | +| DNS record history | 12 months | DB maintenance | +| Metering / usage samples | 13 months (billing) | Aggregated then purged | + +Adjust periods in contracts and WHMCS product terms. Document customer-facing retention in your privacy policy (German: *Datenschutzerklärung*). + +## Backup retention + +Backup objects live under `s3://{bucket}/backups/{serverId}/`. The worker applies plan-based retention: + +- Free/starter plans: 7 daily slots +- Pro plans: 14 daily + 4 weekly +- Enterprise: configurable via admin + +Expired backups are deleted from object storage; metadata rows in PostgreSQL are soft-deleted then purged after 7 days. + +## Termination flow + +When WHMCS calls `TerminateAccount`: + +1. Server stopped on node +2. Final backup optional (plan-dependent) +3. World data on node deleted within 24 h +4. S3 prefix `backups/{serverId}/` deleted within 7 days +5. PostgreSQL server row anonymised or hard-deleted per legal requirement + +## GDPR / DSGVO considerations + +- **Right to erasure:** Export then delete user row; cascade removes owned servers +- **Data portability:** World export ZIP + backup download via panel +- **Processor agreement:** Required between you (controller) and HexaHost GameCloud operator if different entity + +Logs may contain IP addresses and user IDs — treat as personal data where applicable. + +## Operational data + +| Store | Purge method | +|-------|--------------| +| PostgreSQL audit | `DELETE FROM audit_events WHERE created_at < NOW() - INTERVAL '24 months'` (automated job) | +| Redis | TTL-based; no long-term PII | +| MinIO access logs | Provider lifecycle rule | + +## Legal hold + +Suspend automated deletion for accounts under litigation hold. Flag in admin metadata; worker skips purge for affected `serverId`. + +## Related + +- [Backup and restore](../operations/backup-restore.md) +- [Object storage](../operations/object-storage.md) +- [Threat model](threat-model.md) — T16 backup integrity diff --git a/docs/security/secrets.md b/docs/security/secrets.md new file mode 100644 index 0000000..cba9385 --- /dev/null +++ b/docs/security/secrets.md @@ -0,0 +1,95 @@ +# Secrets management + +Guidelines for generating, storing, and rotating secrets in HexaHost GameCloud deployments. + +## Classification + +| Secret | Location | Rotation | +|--------|----------|----------| +| `SESSION_SECRET` | `.env.prod` on control plane | Quarterly; invalidates sessions | +| `ENCRYPTION_KEY` | `.env.prod` | Annually; requires re-encryption plan | +| `POSTGRES_PASSWORD` | `.env.prod`, compose | Annually | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `.env.prod`, MinIO IAM | Quarterly | +| `WHMCS_API_SECRET` | GameCloud `.env.prod` + WHMCS addon | On compromise; per installation | +| `WHMCS_WEBHOOK_SECRET` | Both sides | On compromise | +| `NODE_TOKEN` | Per-node enrollment | One-time at enroll; rotate on rebuild | +| Node mTLS keys | `/etc/hgc-node/` on game nodes | Before cert expiry | +| `EDGE_INTERNAL_API_KEY` | Edge gateway + API | Quarterly | +| `RFC2136_KEY_SECRET` | API/worker env | Annually | +| `STRIPE_*` | API env (if used) | Per Stripe dashboard policy | +| Traefik `acme.json` | `/letsencrypt/` | Auto-renewed; backup only | + +## Generation + +```bash +# 32-byte secrets (SESSION_SECRET, ENCRYPTION_KEY, API secrets) +openssl rand -base64 32 + +# WHMCS integration — minimum 32 characters +openssl rand -hex 24 +``` + +Never use development defaults from `.env.example` in production. + +## Storage rules + +1. **Never commit** `.env`, `.env.prod`, or key material to git +2. Restrict file permissions: `chmod 600 .env.prod`, owner `gamecloud` +3. Prefer secret manager (HashiCorp Vault, SOPS, cloud provider SM) over plain files for multi-host +4. Ansible: use `ansible-vault` for inventory secrets referenced in `deploy/ansible/` + +## Distribution + +| From | To | Channel | +|------|-----|---------| +| Operator | Control plane | SSH + encrypted archive | +| Control plane | WHMCS admin | Out-of-band (password manager share) | +| Control plane | Game node | Enrollment token via secure ticket | + +WHMCS **Integration ID** is not secret but must match exactly on both sides. + +## Rotation procedures + +### SESSION_SECRET + +1. Generate new value +2. Update `.env.prod`, restart `api` and `web` +3. All users must log in again + +### WHMCS_API_SECRET + +1. Update GameCloud `.env.prod` and restart API +2. Update WHMCS addon **API Secret** immediately after — expect brief auth failures +3. No WHMCS module reinstall required + +### Database password + +1. `ALTER USER gamecloud PASSWORD '...'` in PostgreSQL +2. Update `DATABASE_URL` / `POSTGRES_PASSWORD` in `.env.prod` +3. Restart `api`, `worker` + +### Node token compromise + +1. Revoke token in admin API for affected `NODE_ID` +2. Issue new enrollment token +3. Reinstall node-agent with new token and fresh mTLS cert + +## Logging and redaction + +- Structured logs must not print env dumps or Authorization headers +- WHMCS module calls redact `password`, `secret`, `apiSecret` in module logs +- OpenTelemetry spans must not include query strings with tokens + +## Development vs production + +| Variable | Development | Production | +|----------|-------------|------------| +| `INTEGRATION_MTLS_ENABLED` | `false` | `true` (recommended) | +| `NODE_TLS_SKIP_VERIFY` | `true` | `false` | +| Default MinIO credentials | Allowed | **Forbidden** | + +## Related + +- [Threat model](threat-model.md) +- [WHMCS security](../integrations/whmcs/security.md) +- [Data retention](data-retention.md) diff --git a/integrations/whmcs/composer.json b/integrations/whmcs/composer.json new file mode 100644 index 0000000..82b378d --- /dev/null +++ b/integrations/whmcs/composer.json @@ -0,0 +1,17 @@ +{ + "name": "hexahost/whmcs-integration-dev", + "description": "Development dependencies for HexaHost GameCloud WHMCS integration tests", + "type": "project", + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "autoload-dev": { + "classmap": [ + "modules/servers/hexagamecloud/lib/" + ] + }, + "scripts": { + "test": "phpunit", + "test:unit": "phpunit --testsuite Unit" + } +} diff --git a/integrations/whmcs/modules/servers/hexagamecloud/README.md b/integrations/whmcs/modules/servers/hexagamecloud/README.md index aaf1670..4937ab1 100644 --- a/integrations/whmcs/modules/servers/hexagamecloud/README.md +++ b/integrations/whmcs/modules/servers/hexagamecloud/README.md @@ -1,7 +1,31 @@ -# Server provisioning module placeholder +# HexaHost GameCloud WHMCS Server Module -Implementation planned in WHMCS Phase A–B. +Provisioning module for WHMCS that integrates with the GameCloud Integration API. -Target install path: `/modules/servers/hexagamecloud/` +## Features -See `integrations/whmcs/README.md` for the full module layout. +- Provision, suspend, unsuspend, terminate, renew, change package +- Client-area SSO to the GameCloud panel +- Custom actions: **Start**, **Stop**, **Restart**, **Backup** (Integration API `actions/*`) +- Admin service tab with live reconcile snapshot +- DE/EN language files + +## Configuration + +| Field | Description | +|-------|-------------| +| Hostname | GameCloud API base URL (e.g. `https://api.example.net`) | +| Username | WHMCS integration ID | +| Password | Integration API secret | + +## Build package + +```bash +./build-package.sh +``` + +## Tests + +```bash +composer test +``` diff --git a/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php b/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php index 1751e2f..8130358 100644 --- a/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php +++ b/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php @@ -1,12 +1,18 @@ hexagamecloud_resolveRenewInvoiceId($params), + 'periodEnd' => hexagamecloud_formatIso8601($params['nextduedate'] ?? null), + ], static fn ($value) => $value !== null && $value !== ''); + + $client->renewService( + $serviceId, + $payload, + HexaGameCloudIdempotency::renewKey($serviceId, $params) + ); + + return 'success'; + } catch (Throwable $exception) { + logModuleCall( + 'hexagamecloud', + __FUNCTION__, + $params, + $exception->getMessage(), + $exception->getMessage(), + ['password', 'secret', 'apiSecret'] + ); + return $exception->getMessage(); + } +} + function hexagamecloud_ChangePackage(array $params): string { try { @@ -121,6 +157,137 @@ function hexagamecloud_AdminSingleSignOn(array $params) return hexagamecloud_requestSso($params, 'admin'); } +function hexagamecloud_ClientArea(array $params): array +{ + hexagamecloud_loadLanguage($params); + + $serviceId = (string) $params['serviceid']; + $snapshot = hexagamecloud_fetchServiceSnapshot($params); + $server = $snapshot['service']['server'] ?? []; + $linkStatus = (string) ($snapshot['service']['status'] ?? 'PENDING'); + + return [ + 'templatefile' => 'templates/clientarea', + 'vars' => [ + 'LANG' => hexagamecloud_langVars(), + 'apiWarning' => $snapshot['warning'], + 'notProvisioned' => $linkStatus === 'PENDING' && ($server['id'] ?? '') === '', + 'serverName' => (string) ($server['name'] ?? ($params['domain'] ?? '')), + 'technicalStatus' => (string) ($server['status'] ?? 'UNKNOWN'), + 'joinAddress' => (string) ($server['joinHostname'] ?? '-'), + 'edition' => (string) ($server['edition'] ?? 'JAVA'), + 'softwareFamily' => (string) ($server['softwareFamily'] ?? HexaGameCloudProductMapping::resolveSoftwareFamily($params)), + 'minecraftVersion' => (string) ($server['minecraftVersion'] ?? HexaGameCloudProductMapping::resolveMinecraftVersion($params)), + 'planSlug' => HexaGameCloudProductMapping::resolvePlanSlug($params), + 'ramMb' => (int) ($server['ramMb'] ?? 0), + 'nextDueDate' => (string) ($params['nextduedate'] ?? '-'), + 'billingStatus' => $linkStatus === 'SUSPENDED' + ? hexagamecloud_lang('hexagamecloud_suspended_yes') + : hexagamecloud_lang('hexagamecloud_suspended_no'), + 'lastSync' => $snapshot['syncedAt'], + ], + ]; +} + +function hexagamecloud_ClientAreaCustomButtonArray(): array +{ + return [ + hexagamecloud_lang('hexagamecloud_manage_panel') => 'ManagePanel', + hexagamecloud_lang('hexagamecloud_action_start') => 'StartServer', + hexagamecloud_lang('hexagamecloud_action_stop') => 'StopServer', + hexagamecloud_lang('hexagamecloud_action_restart') => 'RestartServer', + hexagamecloud_lang('hexagamecloud_action_backup') => 'BackupServer', + ]; +} + +function hexagamecloud_StartServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'start'); +} + +function hexagamecloud_StopServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'stop'); +} + +function hexagamecloud_RestartServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'restart'); +} + +function hexagamecloud_BackupServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'backup'); +} + +function hexagamecloud_serviceAction(array $params, string $action): string +{ + try { + $client = new HexaGameCloudApiClient($params); + $serviceId = (string) $params['serviceid']; + if ($action === 'start') { + $client->startService($serviceId); + } elseif ($action === 'stop') { + $client->stopService($serviceId); + } elseif ($action === 'restart') { + $client->restartService($serviceId); + } else { + $client->backupService($serviceId); + } + return 'success'; + } catch (Throwable $exception) { + logModuleCall('hexagamecloud', $action, $params, $exception->getMessage()); + return $exception->getMessage(); + } +} + +function hexagamecloud_ManagePanel(array $params): array +{ + return hexagamecloud_requestSso($params, 'service'); +} + +function hexagamecloud_AdminServicesTabFields(array $params): array +{ + hexagamecloud_loadLanguage($params); + + $serviceId = (string) $params['serviceid']; + $snapshot = hexagamecloud_fetchServiceSnapshot($params); + $service = $snapshot['service']; + $server = $service['server'] ?? []; + $metadata = HexaGameCloudServiceMetadata::load($serviceId); + $warning = $snapshot['warning'] !== null + ? '
    ' . htmlspecialchars($snapshot['warning'], ENT_QUOTES, 'UTF-8') . '
    ' + : ''; + + return [ + hexagamecloud_lang('hexagamecloud_admin_server_id') => $warning . htmlspecialchars((string) ($server['id'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_user_id') => htmlspecialchars((string) ($server['userId'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_link_status') => htmlspecialchars((string) ($service['status'] ?? 'PENDING'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_technical_status') => htmlspecialchars((string) ($server['status'] ?? 'UNKNOWN'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_node') => htmlspecialchars((string) ($server['nodeId'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_host_port') => htmlspecialchars((string) ($server['hostPort'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_plan') => htmlspecialchars(HexaGameCloudProductMapping::resolvePlanSlug($params), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_ram') => htmlspecialchars((string) ((int) ($server['ramMb'] ?? 0)) . ' MiB', ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_last_sync') => htmlspecialchars((string) ($snapshot['syncedAt'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_correlation_note') => '', + ]; +} + +function hexagamecloud_AdminServicesTabFieldsSave(array $params): void +{ + $serviceId = (string) $params['serviceid']; + $note = trim((string) ($params['hexagamecloud_correlation_note'] ?? '')); + $existing = HexaGameCloudServiceMetadata::load($serviceId); + + HexaGameCloudServiceMetadata::save($serviceId, array_merge($existing, [ + 'correlation_note' => $note, + 'updated_at' => gmdate('c'), + 'updated_by_admin_id' => (string) ($params['adminid'] ?? ''), + ])); +} + function hexagamecloud_TestConnection(array $params): array { try { @@ -233,6 +400,103 @@ function hexagamecloud_lifecycleAction(array $params, string $action, array $bod } } +function hexagamecloud_fetchServiceSnapshot(array $params): array +{ + $serviceId = (string) $params['serviceid']; + $cached = HexaGameCloudServiceMetadata::load($serviceId); + $snapshot = [ + 'service' => $cached['service'] ?? [ + 'externalServiceId' => $serviceId, + 'status' => 'PENDING', + 'server' => [], + ], + 'warning' => null, + 'syncedAt' => $cached['synced_at'] ?? null, + ]; + + try { + $client = new HexaGameCloudApiClient($params); + $service = $client->getService($serviceId); + $snapshot['service'] = $service; + $snapshot['syncedAt'] = gmdate('c'); + + HexaGameCloudServiceMetadata::save($serviceId, [ + 'service' => $service, + 'synced_at' => $snapshot['syncedAt'], + 'correlation_note' => $cached['correlation_note'] ?? '', + 'updated_by_admin_id' => $cached['updated_by_admin_id'] ?? '', + ]); + } catch (Throwable $exception) { + logModuleCall('hexagamecloud', 'fetchServiceSnapshot', $params, $exception->getMessage()); + $snapshot['warning'] = hexagamecloud_lang('hexagamecloud_api_unavailable'); + } + + return $snapshot; +} + +function hexagamecloud_resolveRenewInvoiceId(array $params): ?string +{ + $invoiceId = (string) ($params['invoiceid'] ?? ''); + if ($invoiceId !== '') { + return $invoiceId; + } + + if (!empty($params['model']['invoiceid'])) { + return (string) $params['model']['invoiceid']; + } + + return null; +} + +function hexagamecloud_formatIso8601(?string $value): ?string +{ + if ($value === null || trim($value) === '') { + return null; + } + + $timestamp = strtotime($value); + if ($timestamp === false) { + return null; + } + + return gmdate('c', $timestamp); +} + +function hexagamecloud_loadLanguage(array $params): void +{ + static $loaded = false; + if ($loaded) { + return; + } + + $language = strtolower((string) ($params['clientsdetails']['language'] ?? $params['language'] ?? 'english')); + if (!in_array($language, ['english', 'german'], true)) { + $language = 'english'; + } + + $langFile = __DIR__ . '/lang/' . $language . '.php'; + if (!is_readable($langFile)) { + $langFile = __DIR__ . '/lang/english.php'; + } + + require $langFile; + $loaded = true; +} + +function hexagamecloud_lang(string $key): string +{ + global $_LANG; + + return (string) ($_LANG[$key] ?? $key); +} + +function hexagamecloud_langVars(): array +{ + global $_LANG; + + return is_array($_LANG ?? null) ? $_LANG : []; +} + function hexagamecloud_requestSso(array $params, string $kind) { try { diff --git a/integrations/whmcs/modules/servers/hexagamecloud/hooks.php b/integrations/whmcs/modules/servers/hexagamecloud/hooks.php new file mode 100644 index 0000000..18d410e --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/hooks.php @@ -0,0 +1,68 @@ + HEXAGAMECLOUD_SERVER_VERSION, + ]; +}); + +add_hook('AdminAreaHeadOutput', 1, function (array $vars): string { + if (($vars['filename'] ?? '') !== 'clientsservices') { + return ''; + } + + return ''; +}); + +add_hook('ServiceEdit', 1, function (array $vars): void { + if (($vars['moduletype'] ?? '') !== 'hexagamecloud') { + return; + } + + $serviceId = (string) ($vars['serviceid'] ?? ''); + if ($serviceId === '') { + return; + } + + logActivity('HexaGameCloud service edited in WHMCS admin (service #' . $serviceId . ')'); +}); diff --git a/integrations/whmcs/modules/servers/hexagamecloud/lang/english.php b/integrations/whmcs/modules/servers/hexagamecloud/lang/english.php new file mode 100644 index 0000000..042c411 --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/lang/english.php @@ -0,0 +1,35 @@ +request( + 'GET', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) + ); + } + + public function renewService(string $externalServiceId, array $payload, string $idempotencyKey): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/renew', + $payload, + $idempotencyKey + ); + } + public function changePackage(string $externalServiceId, array $payload): array { return $this->request( @@ -173,6 +191,46 @@ final class HexaGameCloudApiClient ); } + public function startService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/start', + [], + 'service:start:' . $externalServiceId + ); + } + + public function stopService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/stop', + [], + 'service:stop:' . $externalServiceId + ); + } + + public function restartService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/restart', + [], + 'service:restart:' . $externalServiceId + ); + } + + public function backupService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/backup', + [], + 'service:backup:' . $externalServiceId + ); + } + private function request(string $method, string $pathWithQuery, array $body = [], ?string $idempotencySuffix = null): array { $pathParts = explode('?', $pathWithQuery, 2); diff --git a/integrations/whmcs/modules/servers/hexagamecloud/lib/Idempotency.php b/integrations/whmcs/modules/servers/hexagamecloud/lib/Idempotency.php new file mode 100644 index 0000000..55c9e23 --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/lib/Idempotency.php @@ -0,0 +1,26 @@ +where('operation', self::OPERATION_PREFIX . $serviceId) + ->orderBy('id', 'desc') + ->first(); + + if ($row === null || empty($row->metadata)) { + return []; + } + + $decoded = json_decode((string) $row->metadata, true); + + return is_array($decoded) ? $decoded : []; + } catch (Throwable) { + return []; + } + } + + public static function save(string $serviceId, array $data): void + { + $payload = json_encode($data, JSON_THROW_ON_ERROR); + + try { + $existing = Capsule::table('mod_hexagamecloud_operations') + ->where('operation', self::OPERATION_PREFIX . $serviceId) + ->orderBy('id', 'desc') + ->first(); + + if ($existing !== null) { + Capsule::table('mod_hexagamecloud_operations') + ->where('id', $existing->id) + ->update([ + 'metadata' => $payload, + 'status' => 'saved', + ]); + return; + } + + Capsule::table('mod_hexagamecloud_operations')->insert([ + 'operation' => self::OPERATION_PREFIX . $serviceId, + 'status' => 'saved', + 'metadata' => $payload, + 'created_at' => gmdate('Y-m-d H:i:s'), + ]); + } catch (Throwable $exception) { + logActivity('HexaGameCloud admin metadata save failed: ' . $exception->getMessage()); + } + } +} diff --git a/integrations/whmcs/modules/servers/hexagamecloud/templates/clientarea.tpl b/integrations/whmcs/modules/servers/hexagamecloud/templates/clientarea.tpl new file mode 100644 index 0000000..2bdf1a3 --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/templates/clientarea.tpl @@ -0,0 +1,39 @@ +
    +
    +

    {$LANG.hexagamecloud_server_overview}

    +
    +
    + {if $apiWarning} +
    {$apiWarning}
    + {/if} + {if $notProvisioned} +

    {$LANG.hexagamecloud_not_provisioned}

    + {else} +
    +
    {$LANG.hexagamecloud_server_name}
    +
    {$serverName}
    +
    {$LANG.hexagamecloud_status}
    +
    {$technicalStatus}
    +
    {$LANG.hexagamecloud_join_address}
    +
    {$joinAddress}
    +
    {$LANG.hexagamecloud_edition}
    +
    {$edition}
    +
    {$LANG.hexagamecloud_software}
    +
    {$softwareFamily}
    +
    {$LANG.hexagamecloud_version}
    +
    {$minecraftVersion}
    +
    {$LANG.hexagamecloud_plan}
    +
    {$planSlug}
    +
    {$LANG.hexagamecloud_ram}
    +
    {$ramMb} MiB
    +
    {$LANG.hexagamecloud_next_due}
    +
    {$nextDueDate}
    +
    {$LANG.hexagamecloud_suspend_status}
    +
    {$billingStatus}
    +
    + {if $lastSync} +

    {$LANG.hexagamecloud_last_sync}: {$lastSync}

    + {/if} + {/if} +
    +
    diff --git a/integrations/whmcs/packaging/build-package.sh b/integrations/whmcs/packaging/build-package.sh new file mode 100755 index 0000000..6182642 --- /dev/null +++ b/integrations/whmcs/packaging/build-package.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGING="${ROOT}/packaging" +MANIFEST="${PACKAGING}/manifest.json" +DIST="${ROOT}/dist" +STAGING="${DIST}/staging" +VERSION="$(python3 -c "import json; print(json.load(open('${MANIFEST}'))['version'])")" +PACKAGE_NAME="hexagamecloud-whmcs-${VERSION}" +ZIP_PATH="${DIST}/${PACKAGE_NAME}.zip" +CHECKSUMS_PATH="${DIST}/checksums.txt" + +rm -rf "${STAGING}" "${ZIP_PATH}" +mkdir -p "${STAGING}/modules/servers" "${STAGING}/modules/addons" "${STAGING}/hooks" "${STAGING}/lib" "${DIST}" + +copy_tree() { + local source="$1" + local target="$2" + if command -v rsync >/dev/null 2>&1; then + rsync -a --exclude '.gitkeep' "${source}/" "${target}/" + else + cp -a "${source}/." "${target}/" + fi +} + +copy_tree "${ROOT}/modules/servers/hexagamecloud" "${STAGING}/modules/servers/hexagamecloud" +copy_tree "${ROOT}/modules/addons/hexagamecloud" "${STAGING}/modules/addons/hexagamecloud" +copy_tree "${ROOT}/hooks" "${STAGING}/hooks" +copy_tree "${ROOT}/lib" "${STAGING}/lib" + +cp "${MANIFEST}" "${STAGING}/manifest.json" + +( + cd "${STAGING}" + zip -r "${ZIP_PATH}" . +) + +SHA256="$(sha256sum "${ZIP_PATH}" | awk '{print $1}')" +SHA512="$(sha512sum "${ZIP_PATH}" | awk '{print $1}')" +FILE_SIZE="$(stat -c '%s' "${ZIP_PATH}")" +BUILT_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +cat > "${CHECKSUMS_PATH}" < + + + + tests/Unit + + + tests/Contract + + + diff --git a/integrations/whmcs/tests/Contract/README.md b/integrations/whmcs/tests/Contract/README.md new file mode 100644 index 0000000..fe15139 --- /dev/null +++ b/integrations/whmcs/tests/Contract/README.md @@ -0,0 +1,30 @@ +# Contract tests + +Contract tests validate WHMCS module requests and responses against the GameCloud integration API (`/api/v1/integrations/whmcs/*`). + +## Scope + +- Request signing headers (`X-HGC-Integration-ID`, `X-HGC-Signature`, `X-HGC-Idempotency-Key`, …) +- Canonical HMAC signature base string +- JSON request/response shapes from `packages/contracts/src/whmcs.ts` +- Idempotent lifecycle operations (`provision`, `suspend`, `renew`, `change-package`) + +## Running + +Contract tests require a reachable GameCloud API or a recorded mock server. They are not executed in CI by default. + +```bash +# From integrations/whmcs after installing dev dependencies: +composer install +./vendor/bin/phpunit --testsuite Contract +``` + +## Fixtures + +Sample WHMCS `$params` payloads and signed API examples will live under `tests/Fixtures/` as the contract suite grows. + +## Related + +- OpenAPI / Zod contracts: `packages/contracts/src/whmcs.ts` +- Server module client: `modules/servers/hexagamecloud/lib/ApiClient.php` +- Addon module client: `modules/addons/hexagamecloud/lib/ApiClient.php` diff --git a/integrations/whmcs/tests/Unit/IdempotencyTest.php b/integrations/whmcs/tests/Unit/IdempotencyTest.php new file mode 100644 index 0000000..ffa6381 --- /dev/null +++ b/integrations/whmcs/tests/Unit/IdempotencyTest.php @@ -0,0 +1,53 @@ +assertSame('service:renew:12345:invoice-99', $key); + } + + public function testServiceOperationKeyFallsBackToDateSuffix(): void + { + $key = HexaGameCloudIdempotency::serviceOperationKey('suspend', '42', null); + + $this->assertStringStartsWith('service:suspend:42:', $key); + $this->assertMatchesRegularExpression('/^service:suspend:42:\d{4}-\d{2}-\d{2}$/', $key); + } + + public function testRenewKeyPrefersInvoiceId(): void + { + $key = HexaGameCloudIdempotency::renewKey('9001', [ + 'invoiceid' => '7788', + 'renewalid' => '5555', + ]); + + $this->assertSame('service:renew:9001:7788', $key); + } + + public function testRenewKeyUsesRenewalIdWhenInvoiceMissing(): void + { + $key = HexaGameCloudIdempotency::renewKey('9001', [ + 'renewalid' => '5555', + ]); + + $this->assertSame('service:renew:9001:5555', $key); + } + + public function testRenewKeyUsesModelInvoiceId(): void + { + $key = HexaGameCloudIdempotency::renewKey('9001', [ + 'model' => ['invoiceid' => '6611'], + ]); + + $this->assertSame('service:renew:9001:6611', $key); + } +} diff --git a/packages/catalog/src/curseforge.ts b/packages/catalog/src/curseforge.ts new file mode 100644 index 0000000..fbdaf2e --- /dev/null +++ b/packages/catalog/src/curseforge.ts @@ -0,0 +1,74 @@ +export interface CurseForgeProject { + id: number; + name: string; + slug: string; + summary: string; + downloadCount: number; + logo?: { thumbnailUrl?: string }; +} + +export interface CurseForgeSearchResponse { + data: CurseForgeProject[]; + pagination: { totalCount: number }; +} + +export interface CurseForgeFile { + id: number; + displayName: string; + fileName: string; + downloadUrl: string; + fileLength: number; + gameVersions: string[]; + modLoader?: number; +} + +const CURSEFORGE_API = 'https://api.curseforge.com/v1'; + +function apiKey(): string { + const key = process.env['CURSEFORGE_API_KEY']; + if (!key) { + throw new Error('CURSEFORGE_API_KEY is not configured'); + } + return key; +} + +async function curseforgeFetch(path: string): Promise { + const response = await fetch(`${CURSEFORGE_API}${path}`, { + headers: { + 'x-api-key': apiKey(), + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`CurseForge API error: ${response.status}`); + } + + return (await response.json()) as T; +} + +export async function searchCurseForgeMods(input: { + gameId?: number; + searchFilter: string; + pageSize?: number; + classId?: number; +}): Promise { + const params = new URLSearchParams({ + gameId: String(input.gameId ?? 432), + searchFilter: input.searchFilter, + pageSize: String(input.pageSize ?? 20), + }); + if (input.classId !== undefined) { + params.set('classId', String(input.classId)); + } + + return curseforgeFetch(`/mods/search?${params.toString()}`); +} + +export async function getCurseForgeModFiles(modId: number): Promise<{ data: CurseForgeFile[] }> { + return curseforgeFetch<{ data: CurseForgeFile[] }>(`/mods/${modId}/files`); +} + +export async function getCurseForgeMod(modId: number): Promise<{ data: CurseForgeProject }> { + return curseforgeFetch<{ data: CurseForgeProject }>(`/mods/${modId}`); +} diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts index 7584d4b..9415cfd 100644 --- a/packages/catalog/src/index.ts +++ b/packages/catalog/src/index.ts @@ -11,6 +11,15 @@ export { type SoftwareFamily, } from './modrinth'; +export { + searchCurseForgeMods, + getCurseForgeModFiles, + getCurseForgeMod, + type CurseForgeProject, + type CurseForgeSearchResponse, + type CurseForgeFile, +} from './curseforge'; + export { resolveModrinthLoader, resolveProjectType, diff --git a/packages/contracts/src/abuse.ts b/packages/contracts/src/abuse.ts new file mode 100644 index 0000000..9067f3b --- /dev/null +++ b/packages/contracts/src/abuse.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +export const abuseReportStatusSchema = z.enum([ + 'OPEN', + 'INVESTIGATING', + 'RESOLVED', + 'DISMISSED', +]); + +export const createAbuseReportSchema = z.object({ + reason: z.string().min(1).max(256), + details: z.string().max(4096).optional(), + targetUserId: z.string().uuid().optional(), + serverId: z.string().uuid().optional(), +}); + +export const abuseReportSchema = z.object({ + id: z.string().uuid(), + reporterId: z.string().uuid().nullable(), + targetUserId: z.string().uuid().nullable(), + serverId: z.string().uuid().nullable(), + status: abuseReportStatusSchema, + reason: z.string(), + details: z.string().nullable(), + resolution: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const abuseReportListResponseSchema = z.object({ + reports: z.array(abuseReportSchema), +}); + +export const updateAbuseReportSchema = z.object({ + status: abuseReportStatusSchema.optional(), + resolution: z.string().max(4096).optional(), +}); + +export type CreateAbuseReport = z.infer; +export type AbuseReport = z.infer; +export type AbuseReportListResponse = z.infer; +export type UpdateAbuseReport = z.infer; diff --git a/packages/contracts/src/compliance.ts b/packages/contracts/src/compliance.ts new file mode 100644 index 0000000..ea0c52c --- /dev/null +++ b/packages/contracts/src/compliance.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const dataExportRequestSchema = z.object({ + id: z.string().uuid(), + status: z.enum(['PENDING', 'PROCESSING', 'READY', 'FAILED', 'EXPIRED']), + downloadUrl: z.string().url().nullable(), + expiresAt: z.string().datetime().nullable(), + completedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +export const accountDeletionRequestSchema = z.object({ + id: z.string().uuid(), + status: z.enum(['PENDING', 'SCHEDULED', 'COMPLETED', 'CANCELLED']), + scheduledFor: z.string().datetime(), + completedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +export const requestAccountDeletionSchema = z.object({ + confirmEmail: z.string().email(), +}); + +export type DataExportRequest = z.infer; +export type AccountDeletionRequest = z.infer; diff --git a/packages/contracts/src/files.ts b/packages/contracts/src/files.ts index d007760..0f99c03 100644 --- a/packages/contracts/src/files.ts +++ b/packages/contracts/src/files.ts @@ -21,6 +21,7 @@ export const fileContentSchema = z.object({ path: z.string(), content: z.string(), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + version: z.number().int().optional(), }); export const readFileContentQuerySchema = z.object({ @@ -31,6 +32,27 @@ export const updateFileContentSchema = z.object({ path: z.string().min(1), content: z.string(), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + version: z.number().int().optional(), +}); + +export const uploadFileSchema = z.object({ + path: z.string().min(1), + content: z.string(), + encoding: z.enum(['utf-8', 'base64']).default('base64'), +}); + +export const filePathSchema = z.object({ + path: z.string().min(1), +}); + +export const archiveFilesSchema = z.object({ + paths: z.array(z.string().min(1)).min(1).max(100), + archiveName: z.string().min(1).max(128).optional(), +}); + +export const unarchiveFileSchema = z.object({ + archivePath: z.string().min(1), + destinationPath: z.string().default('.'), }); export type ListFilesQuery = z.infer; @@ -39,3 +61,6 @@ export type FileListResponse = z.infer; export type FileContent = z.infer; export type ReadFileContentQuery = z.infer; export type UpdateFileContent = z.infer; +export type UploadFile = z.infer; +export type ArchiveFiles = z.infer; +export type UnarchiveFile = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 90bcacf..5b6448b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -61,6 +61,7 @@ export { serverEditionSchema, softwareFamilySchema, createServerRequestSchema, + updateServerRequestSchema, serverResponseSchema, serverListResponseSchema, serverActionResponseSchema, @@ -68,6 +69,7 @@ export { type ServerEdition, type SoftwareFamily, type CreateServerRequest, + type UpdateServerRequest, type ServerResponse, type ServerListResponse, type ServerActionResponse, @@ -80,12 +82,19 @@ export { fileContentSchema, readFileContentQuerySchema, updateFileContentSchema, + uploadFileSchema, + filePathSchema, + archiveFilesSchema, + unarchiveFileSchema, type ListFilesQuery, type FileEntry, type FileListResponse, type FileContent, type ReadFileContentQuery, type UpdateFileContent, + type UploadFile, + type ArchiveFiles, + type UnarchiveFile, } from './files'; export { @@ -108,13 +117,54 @@ export { onlinePlayerSchema, whitelistEntrySchema, operatorEntrySchema, + banEntrySchema, playerListResponseSchema, + addWhitelistSchema, + addOperatorSchema, + removePlayerEntrySchema, + addBanSchema, type OnlinePlayer, type WhitelistEntry, type OperatorEntry, + type BanEntry, type PlayerListResponse, + type AddWhitelist, + type AddOperator, + type RemovePlayerEntry, + type AddBan, } from './players'; +export { + scheduleTaskTypeSchema, + scheduleSchema, + createScheduleSchema, + updateScheduleSchema, + scheduleListResponseSchema, + type Schedule, + type CreateSchedule, + type UpdateSchedule, + type ScheduleListResponse, +} from './schedules'; + +export { + notificationTypeSchema, + notificationChannelSchema, + notificationSchema, + notificationListResponseSchema, + notificationPreferenceSchema, + updateNotificationPreferencesSchema, + type Notification, + type NotificationListResponse, + type UpdateNotificationPreferences, +} from './notifications'; + +export { + reinstallServerSchema, + reinstallServerResponseSchema, + type ReinstallServer, + type ReinstallServerResponse, +} from './software'; + export { backupStatusSchema, backupTypeSchema, @@ -273,3 +323,62 @@ export { type WhmcsMtlsStatusResponse, type WhmcsValidatePlanResponse, } from './whmcs'; + +export { + dataExportRequestSchema, + accountDeletionRequestSchema, + requestAccountDeletionSchema, + type DataExportRequest, + type AccountDeletionRequest, +} from './compliance'; + +export { + whmcsServiceActionRequestSchema, + whmcsServiceActionResponseSchema, + type WhmcsServiceActionRequest, + type WhmcsServiceActionResponse, +} from './whmcs-actions'; + +export { + organizationMemberRoleSchema, + organizationSchema, + organizationListResponseSchema, + createOrganizationSchema, + organizationMemberSchema, + organizationMemberListResponseSchema, + inviteOrganizationMemberSchema, + organizationInviteResponseSchema, + type Organization, + type OrganizationListResponse, + type CreateOrganization, + type OrganizationMember, + type OrganizationMemberListResponse, + type InviteOrganizationMember, + type OrganizationInviteResponse, +} from './organizations'; + +export { + supportTicketStatusSchema, + supportTicketPrioritySchema, + supportTicketMessageSchema, + supportTicketSchema, + supportTicketListResponseSchema, + createSupportTicketSchema, + addSupportTicketMessageSchema, + type SupportTicket, + type SupportTicketListResponse, + type CreateSupportTicket, + type AddSupportTicketMessage, +} from './support'; + +export { + abuseReportStatusSchema, + createAbuseReportSchema, + abuseReportSchema, + abuseReportListResponseSchema, + updateAbuseReportSchema, + type CreateAbuseReport, + type AbuseReport, + type AbuseReportListResponse, + type UpdateAbuseReport, +} from './abuse'; diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts new file mode 100644 index 0000000..d1f5624 --- /dev/null +++ b/packages/contracts/src/notifications.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +export const notificationTypeSchema = z.enum([ + 'SERVER_READY', + 'SERVER_START_FAILED', + 'SERVER_STOPPED', + 'IDLE_STOP_WARNING', + 'BACKUP_SUCCESS', + 'BACKUP_FAILED', + 'RESTORE_SUCCESS', + 'RESTORE_FAILED', + 'CREDITS_LOW', + 'BILLING_ISSUE', + 'INVITE', + 'NODE_INCIDENT', + 'MAINTENANCE', + 'SECURITY', + 'NEW_LOGIN', + 'TWO_FA_CHANGED', +]); + +export const notificationChannelSchema = z.enum(['IN_APP', 'EMAIL', 'WEBHOOK', 'DISCORD']); + +export const notificationSchema = z.object({ + id: z.string().uuid(), + type: notificationTypeSchema, + channel: notificationChannelSchema, + title: z.string(), + body: z.string(), + readAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +export const notificationListResponseSchema = z.object({ + notifications: z.array(notificationSchema), + unreadCount: z.number().int(), +}); + +export const notificationPreferenceSchema = z.object({ + type: notificationTypeSchema, + channel: notificationChannelSchema, + enabled: z.boolean(), +}); + +export const updateNotificationPreferencesSchema = z.object({ + preferences: z.array(notificationPreferenceSchema), +}); + +export type Notification = z.infer; +export type NotificationListResponse = z.infer; +export type UpdateNotificationPreferences = z.infer; diff --git a/packages/contracts/src/organizations.ts b/packages/contracts/src/organizations.ts new file mode 100644 index 0000000..a848109 --- /dev/null +++ b/packages/contracts/src/organizations.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +export const organizationMemberRoleSchema = z.enum(['OWNER', 'ADMIN', 'MEMBER']); + +export const organizationSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + role: organizationMemberRoleSchema, + createdAt: z.string().datetime(), +}); + +export const organizationListResponseSchema = z.object({ + organizations: z.array(organizationSchema), +}); + +export const createOrganizationSchema = z.object({ + name: z.string().min(1).max(128), + slug: z.string().min(2).max(64).regex(/^[a-z0-9-]+$/).optional(), +}); + +export const organizationMemberSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + role: organizationMemberRoleSchema, + email: z.string().email(), + username: z.string(), + displayName: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +export const organizationMemberListResponseSchema = z.object({ + members: z.array(organizationMemberSchema), +}); + +export const inviteOrganizationMemberSchema = z.object({ + email: z.string().email(), + role: organizationMemberRoleSchema.default('MEMBER'), +}); + +export const organizationInviteResponseSchema = z.object({ + id: z.string().uuid(), + email: z.string().email(), + role: organizationMemberRoleSchema, + expiresAt: z.string().datetime(), +}); + +export type Organization = z.infer; +export type OrganizationListResponse = z.infer; +export type CreateOrganization = z.infer; +export type OrganizationMember = z.infer; +export type OrganizationMemberListResponse = z.infer< + typeof organizationMemberListResponseSchema +>; +export type InviteOrganizationMember = z.infer; +export type OrganizationInviteResponse = z.infer; diff --git a/packages/contracts/src/players.ts b/packages/contracts/src/players.ts index 94292ff..70454ec 100644 --- a/packages/contracts/src/players.ts +++ b/packages/contracts/src/players.ts @@ -17,13 +17,48 @@ export const operatorEntrySchema = z.object({ bypassesPlayerLimit: z.boolean().optional(), }); +export const banEntrySchema = z.object({ + uuid: z.string().uuid().optional(), + name: z.string(), + reason: z.string().optional(), + source: z.string().optional(), + expires: z.string().optional(), +}); + export const playerListResponseSchema = z.object({ online: z.array(onlinePlayerSchema), whitelist: z.array(whitelistEntrySchema), operators: z.array(operatorEntrySchema), + bans: z.array(banEntrySchema).optional(), +}); + +export const addWhitelistSchema = z.object({ + uuid: z.string().uuid(), + name: z.string().min(1).max(16), +}); + +export const addOperatorSchema = z.object({ + uuid: z.string().uuid(), + name: z.string().min(1).max(16), + level: z.number().int().min(0).max(4).default(4), + bypassesPlayerLimit: z.boolean().default(false), +}); + +export const removePlayerEntrySchema = z.object({ + uuid: z.string().uuid(), +}); + +export const addBanSchema = z.object({ + name: z.string().min(1).max(16), + reason: z.string().max(256).optional(), }); export type OnlinePlayer = z.infer; export type WhitelistEntry = z.infer; export type OperatorEntry = z.infer; +export type BanEntry = z.infer; export type PlayerListResponse = z.infer; +export type AddWhitelist = z.infer; +export type AddOperator = z.infer; +export type RemovePlayerEntry = z.infer; +export type AddBan = z.infer; diff --git a/packages/contracts/src/schedules.ts b/packages/contracts/src/schedules.ts new file mode 100644 index 0000000..5953424 --- /dev/null +++ b/packages/contracts/src/schedules.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +export const scheduleTaskTypeSchema = z.enum([ + 'START', + 'STOP', + 'RESTART', + 'BACKUP', + 'CONSOLE_COMMAND', + 'UPDATE_CHECK', + 'MAINTENANCE', +]); + +export const scheduledTaskSchema = z.object({ + id: z.string().uuid(), + taskType: scheduleTaskTypeSchema, + payload: z.record(z.unknown()).nullable(), +}); + +export const scheduleSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + timezone: z.string(), + cronExpr: z.string(), + enabled: z.boolean(), + nextRunAt: z.string().datetime().nullable(), + lastRunAt: z.string().datetime().nullable(), + tasks: z.array(scheduledTaskSchema), +}); + +export const createScheduleSchema = z.object({ + name: z.string().min(1).max(64), + timezone: z.string().default('UTC'), + cronExpr: z.string().min(5).max(128), + enabled: z.boolean().default(true), + tasks: z + .array( + z.object({ + taskType: scheduleTaskTypeSchema, + payload: z.record(z.unknown()).optional(), + }), + ) + .min(1), +}); + +export const updateScheduleSchema = createScheduleSchema.partial(); + +export const scheduleListResponseSchema = z.object({ + schedules: z.array(scheduleSchema), +}); + +export type Schedule = z.infer; +export type CreateSchedule = z.infer; +export type UpdateSchedule = z.infer; +export type ScheduleListResponse = z.infer; diff --git a/packages/contracts/src/servers.ts b/packages/contracts/src/servers.ts index fe4a88a..ab02e69 100644 --- a/packages/contracts/src/servers.ts +++ b/packages/contracts/src/servers.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; export const gameServerStatusSchema = z.enum([ 'DRAFT', + 'ALLOCATING', 'PROVISIONING', 'INSTALLING', 'STOPPED', @@ -11,6 +12,9 @@ export const gameServerStatusSchema = z.enum([ 'STOPPING', 'BACKING_UP', 'RESTORING', + 'MIGRATING', + 'SUSPENDED', + 'MAINTENANCE', 'UNKNOWN', 'ERROR', 'DELETING', @@ -42,6 +46,11 @@ export const createServerRequestSchema = z.object({ planId: z.string().uuid().optional(), }); +export const updateServerRequestSchema = z.object({ + name: z.string().min(1).max(64).optional(), + idleShutdownEnabled: z.boolean().optional(), +}); + export const serverResponseSchema = z.object({ id: z.string().uuid(), userId: z.string().uuid(), @@ -82,6 +91,7 @@ export type GameServerStatus = z.infer; export type ServerEdition = z.infer; export type SoftwareFamily = z.infer; export type CreateServerRequest = z.infer; +export type UpdateServerRequest = z.infer; export type ServerResponse = z.infer; export type ServerListResponse = z.infer; export type ServerActionResponse = z.infer; diff --git a/packages/contracts/src/software.ts b/packages/contracts/src/software.ts new file mode 100644 index 0000000..675e416 --- /dev/null +++ b/packages/contracts/src/software.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { softwareFamilySchema } from './servers'; + +export const reinstallServerSchema = z.object({ + softwareFamily: softwareFamilySchema, + minecraftVersion: z.string().min(1).max(32), + createBackup: z.boolean().default(true), +}); + +export const reinstallServerResponseSchema = z.object({ + serverId: z.string().uuid(), + jobId: z.string(), + status: z.string(), +}); + +export type ReinstallServer = z.infer; +export type ReinstallServerResponse = z.infer; diff --git a/packages/contracts/src/support.ts b/packages/contracts/src/support.ts new file mode 100644 index 0000000..1363cee --- /dev/null +++ b/packages/contracts/src/support.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const supportTicketStatusSchema = z.enum([ + 'OPEN', + 'IN_PROGRESS', + 'WAITING', + 'RESOLVED', + 'CLOSED', +]); + +export const supportTicketPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']); + +export const supportTicketMessageSchema = z.object({ + id: z.string().uuid(), + authorId: z.string().uuid().nullable(), + isInternal: z.boolean(), + body: z.string(), + createdAt: z.string().datetime(), +}); + +export const supportTicketSchema = z.object({ + id: z.string().uuid(), + subject: z.string(), + status: supportTicketStatusSchema, + priority: supportTicketPrioritySchema, + serverId: z.string().uuid().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + messages: z.array(supportTicketMessageSchema).optional(), +}); + +export const supportTicketListResponseSchema = z.object({ + tickets: z.array(supportTicketSchema), +}); + +export const createSupportTicketSchema = z.object({ + subject: z.string().min(1).max(256), + body: z.string().min(1).max(8192), + priority: supportTicketPrioritySchema.default('NORMAL'), + serverId: z.string().uuid().optional(), +}); + +export const addSupportTicketMessageSchema = z.object({ + body: z.string().min(1).max(8192), +}); + +export type SupportTicket = z.infer; +export type SupportTicketListResponse = z.infer; +export type CreateSupportTicket = z.infer; +export type AddSupportTicketMessage = z.infer; diff --git a/packages/contracts/src/whmcs-actions.ts b/packages/contracts/src/whmcs-actions.ts new file mode 100644 index 0000000..217722e --- /dev/null +++ b/packages/contracts/src/whmcs-actions.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { serverActionResponseSchema } from './servers'; + +export const whmcsServiceActionRequestSchema = z.object({ + reason: z.string().max(256).optional(), +}); + +export const whmcsServiceActionResponseSchema = serverActionResponseSchema; + +export type WhmcsServiceActionRequest = z.infer; +export type WhmcsServiceActionResponse = z.infer; diff --git a/packages/database/prisma/migrations/20260705150000_gap_closure/migration.sql b/packages/database/prisma/migrations/20260705150000_gap_closure/migration.sql new file mode 100644 index 0000000..bc7af8c --- /dev/null +++ b/packages/database/prisma/migrations/20260705150000_gap_closure/migration.sql @@ -0,0 +1,18 @@ +-- Gap closure migration: permissions, catalog, compliance, WHMCS mappings, server states + +-- AlterEnum +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'ALLOCATING'; +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'MIGRATING'; +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'SUSPENDED'; +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'MAINTENANCE'; + +-- CreateEnum +CREATE TYPE "GameServerMemberRole" AS ENUM ('OWNER', 'ADMIN', 'OPERATOR', 'DEVELOPER', 'VIEWER'); +CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER'); +CREATE TYPE "NotificationChannel" AS ENUM ('IN_APP', 'EMAIL', 'WEBHOOK', 'DISCORD'); +CREATE TYPE "NotificationType" AS ENUM ('SERVER_READY', 'SERVER_START_FAILED', 'SERVER_STOPPED', 'IDLE_STOP_WARNING', 'BACKUP_SUCCESS', 'BACKUP_FAILED', 'RESTORE_SUCCESS', 'RESTORE_FAILED', 'CREDITS_LOW', 'BILLING_ISSUE', 'INVITE', 'NODE_INCIDENT', 'MAINTENANCE', 'SECURITY', 'NEW_LOGIN', 'TWO_FA_CHANGED'); +CREATE TYPE "AbuseReportStatus" AS ENUM ('OPEN', 'INVESTIGATING', 'RESOLVED', 'DISMISSED'); +CREATE TYPE "SupportTicketStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'WAITING', 'RESOLVED', 'CLOSED'); +CREATE TYPE "SupportTicketPriority" AS ENUM ('LOW', 'NORMAL', 'HIGH', 'URGENT'); +CREATE TYPE "ScheduleTaskType" AS ENUM ('START', 'STOP', 'RESTART', 'BACKUP', 'CONSOLE_COMMAND', 'UPDATE_CHECK', 'MAINTENANCE'); +CREATE TYPE "CatalogProviderKind" AS ENUM ('MODRINTH', 'CURSEFORGE', 'MOJANG', 'PAPERMC', 'CUSTOM'); diff --git a/packages/database/prisma/migrations/20260705160000_gap_closure_tables/migration.sql b/packages/database/prisma/migrations/20260705160000_gap_closure_tables/migration.sql new file mode 100644 index 0000000..2f0f800 --- /dev/null +++ b/packages/database/prisma/migrations/20260705160000_gap_closure_tables/migration.sql @@ -0,0 +1,768 @@ +-- Gap closure tables: organizations, catalog, schedules, notifications, compliance, billing, admin + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "organization_members" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER', + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "organization_members_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "organization_invites" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER', + "tokenHash" TEXT NOT NULL, + "invitedById" TEXT NOT NULL, + "expiresAt" TIMESTAMPTZ(3) NOT NULL, + "acceptedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "organization_invites_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_members" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "GameServerMemberRole" NOT NULL DEFAULT 'VIEWER', + "permissions" JSONB, + "invitedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "acceptedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_members_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_properties" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_properties_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_environment_variables" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "isSecret" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_environment_variables_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_secrets" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "valueCiphertext" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_secrets_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_node_capabilities" ( + "id" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "game_node_capabilities_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_node_maintenance_windows" ( + "id" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "startsAt" TIMESTAMPTZ(3) NOT NULL, + "endsAt" TIMESTAMPTZ(3) NOT NULL, + "drainFirst" BOOLEAN NOT NULL DEFAULT true, + "message" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "game_node_maintenance_windows_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "minecraft_versions" ( + "id" TEXT NOT NULL, + "version" TEXT NOT NULL, + "edition" "ServerEdition" NOT NULL DEFAULT 'JAVA', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "minecraft_versions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "software_builds" ( + "id" TEXT NOT NULL, + "family" "SoftwareFamily" NOT NULL, + "minecraftVersionId" TEXT NOT NULL, + "downloadUrl" TEXT, + "sha256" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "software_builds_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "runtime_images" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "imageRef" TEXT NOT NULL, + "digest" TEXT, + "javaVersion" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "runtime_images_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_providers" ( + "id" TEXT NOT NULL, + "kind" "CatalogProviderKind" NOT NULL, + "name" TEXT NOT NULL, + "isEnabled" BOOLEAN NOT NULL DEFAULT false, + "config" JSONB, + "lastSyncAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "catalog_providers_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_projects" ( + "id" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "projectType" TEXT NOT NULL, + "metadata" JSONB, + "syncedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "catalog_projects_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_versions" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "versionNumber" TEXT NOT NULL, + "gameVersions" JSONB, + "loaders" JSONB, + "sha512" TEXT, + "downloadUrl" TEXT, + "syncedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "catalog_versions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_dependencies" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "dependencyId" TEXT NOT NULL, + "dependencyType" TEXT NOT NULL, + + CONSTRAINT "catalog_dependencies_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "worlds" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT false, + "sizeBytes" BIGINT, + "hasLevelDat" BOOLEAN NOT NULL DEFAULT false, + "seed" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "worlds_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "world_archives" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "s3Key" TEXT NOT NULL, + "sha256" TEXT, + "sizeBytes" BIGINT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "world_archives_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "schedules" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "cronExpr" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "nextRunAt" TIMESTAMPTZ(3), + "lastRunAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "schedules_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "scheduled_tasks" ( + "id" TEXT NOT NULL, + "scheduleId" TEXT NOT NULL, + "taskType" "ScheduleTaskType" NOT NULL, + "payload" JSONB, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "scheduled_tasks_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "notifications" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "channel" "NotificationChannel" NOT NULL DEFAULT 'IN_APP', + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "metadata" JSONB, + "readAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "notification_preferences" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "channel" "NotificationChannel" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "abuse_reports" ( + "id" TEXT NOT NULL, + "reporterId" TEXT, + "targetUserId" TEXT, + "serverId" TEXT, + "status" "AbuseReportStatus" NOT NULL DEFAULT 'OPEN', + "reason" TEXT NOT NULL, + "details" TEXT, + "resolution" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "abuse_reports_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "support_tickets" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "serverId" TEXT, + "subject" TEXT NOT NULL, + "status" "SupportTicketStatus" NOT NULL DEFAULT 'OPEN', + "priority" "SupportTicketPriority" NOT NULL DEFAULT 'NORMAL', + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "support_tickets_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "support_ticket_messages" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "authorId" TEXT, + "isInternal" BOOLEAN NOT NULL DEFAULT false, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "support_ticket_messages_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "subscriptions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "startsAt" TIMESTAMPTZ(3) NOT NULL, + "endsAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "invoices" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "subscriptionId" TEXT, + "amountCents" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'EUR', + "status" TEXT NOT NULL, + "externalRef" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "invoices_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "payments" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "amountCents" INTEGER NOT NULL, + "provider" TEXT NOT NULL, + "externalRef" TEXT, + "status" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "payments_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "resource_quotas" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "planId" TEXT, + "key" TEXT NOT NULL, + "limit" INTEGER NOT NULL, + "used" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "resource_quotas_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "security_change_logs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "metadata" JSONB, + "ipAddress" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "security_change_logs_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "data_export_requests" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "downloadUrl" TEXT, + "expiresAt" TIMESTAMPTZ(3), + "completedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "data_export_requests_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "account_deletion_requests" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "scheduledFor" TIMESTAMPTZ(3) NOT NULL, + "completedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "account_deletion_requests_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_user_links" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalUserId" TEXT NOT NULL, + "externalClientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_user_links_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_product_mappings" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalProductId" TEXT NOT NULL, + "planSlug" TEXT NOT NULL, + "defaultEdition" "ServerEdition" NOT NULL DEFAULT 'JAVA', + "defaultSoftware" "SoftwareFamily" NOT NULL DEFAULT 'VANILLA', + "defaultVersion" TEXT NOT NULL DEFAULT '1.21.1', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_product_mappings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_option_mappings" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalOptionId" TEXT NOT NULL, + "externalValueId" TEXT NOT NULL, + "resourceKey" TEXT NOT NULL, + "normalizedValue" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_option_mappings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_addon_mappings" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalAddonId" TEXT NOT NULL, + "featureKey" TEXT NOT NULL, + "deltaValue" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_addon_mappings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "feature_flags" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "description" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "system_settings" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "system_settings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "job_records" ( + "id" TEXT NOT NULL, + "queue" TEXT NOT NULL, + "jobName" TEXT NOT NULL, + "status" "JobStatus" NOT NULL DEFAULT 'PENDING', + "payload" JSONB, + "result" JSONB, + "error" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "startedAt" TIMESTAMPTZ(3), + "completedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "job_records_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "audit_events" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "action" TEXT NOT NULL, + "entityType" TEXT, + "entityId" TEXT, + "metadata" JSONB, + "ipAddress" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "organizations_slug_key" ON "organizations"("slug"); + +CREATE UNIQUE INDEX IF NOT EXISTS "organization_members_organizationId_userId_key" ON "organization_members"("organizationId", "userId"); +CREATE INDEX IF NOT EXISTS "organization_members_userId_idx" ON "organization_members"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "organization_invites_tokenHash_key" ON "organization_invites"("tokenHash"); +CREATE INDEX IF NOT EXISTS "organization_invites_organizationId_idx" ON "organization_invites"("organizationId"); +CREATE INDEX IF NOT EXISTS "organization_invites_email_idx" ON "organization_invites"("email"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_members_serverId_userId_key" ON "game_server_members"("serverId", "userId"); +CREATE INDEX IF NOT EXISTS "game_server_members_userId_idx" ON "game_server_members"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_properties_serverId_key_key" ON "game_server_properties"("serverId", "key"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_environment_variables_serverId_key_key" ON "game_server_environment_variables"("serverId", "key"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_secrets_serverId_name_key" ON "game_server_secrets"("serverId", "name"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_node_capabilities_nodeId_key_key" ON "game_node_capabilities"("nodeId", "key"); + +CREATE INDEX IF NOT EXISTS "game_node_maintenance_windows_nodeId_startsAt_idx" ON "game_node_maintenance_windows"("nodeId", "startsAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "minecraft_versions_version_key" ON "minecraft_versions"("version"); + +CREATE UNIQUE INDEX IF NOT EXISTS "software_builds_family_minecraftVersionId_key" ON "software_builds"("family", "minecraftVersionId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "runtime_images_name_key" ON "runtime_images"("name"); + +CREATE UNIQUE INDEX IF NOT EXISTS "catalog_providers_kind_key" ON "catalog_providers"("kind"); + +CREATE UNIQUE INDEX IF NOT EXISTS "catalog_projects_providerId_externalId_key" ON "catalog_projects"("providerId", "externalId"); +CREATE INDEX IF NOT EXISTS "catalog_projects_slug_idx" ON "catalog_projects"("slug"); + +CREATE UNIQUE INDEX IF NOT EXISTS "catalog_versions_projectId_externalId_key" ON "catalog_versions"("projectId", "externalId"); + +CREATE INDEX IF NOT EXISTS "catalog_dependencies_versionId_idx" ON "catalog_dependencies"("versionId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "worlds_serverId_name_key" ON "worlds"("serverId", "name"); + +CREATE INDEX IF NOT EXISTS "world_archives_worldId_idx" ON "world_archives"("worldId"); + +CREATE INDEX IF NOT EXISTS "schedules_serverId_idx" ON "schedules"("serverId"); + +CREATE INDEX IF NOT EXISTS "scheduled_tasks_scheduleId_idx" ON "scheduled_tasks"("scheduleId"); + +CREATE INDEX IF NOT EXISTS "notifications_userId_readAt_idx" ON "notifications"("userId", "readAt"); +CREATE INDEX IF NOT EXISTS "notifications_createdAt_idx" ON "notifications"("createdAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "notification_preferences_userId_type_channel_key" ON "notification_preferences"("userId", "type", "channel"); + +CREATE INDEX IF NOT EXISTS "abuse_reports_status_idx" ON "abuse_reports"("status"); +CREATE INDEX IF NOT EXISTS "abuse_reports_serverId_idx" ON "abuse_reports"("serverId"); + +CREATE INDEX IF NOT EXISTS "support_tickets_userId_idx" ON "support_tickets"("userId"); +CREATE INDEX IF NOT EXISTS "support_tickets_status_idx" ON "support_tickets"("status"); + +CREATE INDEX IF NOT EXISTS "support_ticket_messages_ticketId_idx" ON "support_ticket_messages"("ticketId"); + +CREATE INDEX IF NOT EXISTS "subscriptions_userId_idx" ON "subscriptions"("userId"); + +CREATE INDEX IF NOT EXISTS "invoices_userId_idx" ON "invoices"("userId"); + +CREATE INDEX IF NOT EXISTS "payments_invoiceId_idx" ON "payments"("invoiceId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "resource_quotas_userId_planId_key_key" ON "resource_quotas"("userId", "planId", "key"); + +CREATE INDEX IF NOT EXISTS "security_change_logs_userId_createdAt_idx" ON "security_change_logs"("userId", "createdAt"); + +CREATE INDEX IF NOT EXISTS "data_export_requests_userId_idx" ON "data_export_requests"("userId"); + +CREATE INDEX IF NOT EXISTS "account_deletion_requests_userId_idx" ON "account_deletion_requests"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_user_links_installationId_externalUserId_key" ON "whmcs_user_links"("installationId", "externalUserId"); +CREATE INDEX IF NOT EXISTS "whmcs_user_links_userId_idx" ON "whmcs_user_links"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_product_mappings_installationId_externalProductId_key" ON "whmcs_product_mappings"("installationId", "externalProductId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_option_mappings_installationId_externalOptionId_externalValueId_key" ON "whmcs_option_mappings"("installationId", "externalOptionId", "externalValueId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_addon_mappings_installationId_externalAddonId_key" ON "whmcs_addon_mappings"("installationId", "externalAddonId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "feature_flags_key_key" ON "feature_flags"("key"); + +CREATE UNIQUE INDEX IF NOT EXISTS "system_settings_key_key" ON "system_settings"("key"); + +CREATE INDEX IF NOT EXISTS "job_records_queue_status_idx" ON "job_records"("queue", "status"); +CREATE INDEX IF NOT EXISTS "job_records_createdAt_idx" ON "job_records"("createdAt"); + +CREATE INDEX IF NOT EXISTS "audit_events_userId_idx" ON "audit_events"("userId"); +CREATE INDEX IF NOT EXISTS "audit_events_action_idx" ON "audit_events"("action"); +CREATE INDEX IF NOT EXISTS "audit_events_entityType_entityId_idx" ON "audit_events"("entityType", "entityId"); +CREATE INDEX IF NOT EXISTS "audit_events_createdAt_idx" ON "audit_events"("createdAt"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "organization_invites" ADD CONSTRAINT "organization_invites_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "organization_invites" ADD CONSTRAINT "organization_invites_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_members" ADD CONSTRAINT "game_server_members_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_members" ADD CONSTRAINT "game_server_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_properties" ADD CONSTRAINT "game_server_properties_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_environment_variables" ADD CONSTRAINT "game_server_environment_variables_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_secrets" ADD CONSTRAINT "game_server_secrets_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_node_capabilities" ADD CONSTRAINT "game_node_capabilities_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_node_maintenance_windows" ADD CONSTRAINT "game_node_maintenance_windows_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "software_builds" ADD CONSTRAINT "software_builds_minecraftVersionId_fkey" FOREIGN KEY ("minecraftVersionId") REFERENCES "minecraft_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "catalog_projects" ADD CONSTRAINT "catalog_projects_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "catalog_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "catalog_versions" ADD CONSTRAINT "catalog_versions_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "catalog_projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "catalog_dependencies" ADD CONSTRAINT "catalog_dependencies_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "catalog_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "worlds" ADD CONSTRAINT "worlds_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "world_archives" ADD CONSTRAINT "world_archives_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "worlds"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "schedules" ADD CONSTRAINT "schedules_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "scheduled_tasks" ADD CONSTRAINT "scheduled_tasks_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "abuse_reports" ADD CONSTRAINT "abuse_reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "support_ticket_messages" ADD CONSTRAINT "support_ticket_messages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "support_tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "payments" ADD CONSTRAINT "payments_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "security_change_logs" ADD CONSTRAINT "security_change_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "data_export_requests" ADD CONSTRAINT "data_export_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "account_deletion_requests" ADD CONSTRAINT "account_deletion_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_user_links" ADD CONSTRAINT "whmcs_user_links_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_user_links" ADD CONSTRAINT "whmcs_user_links_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_product_mappings" ADD CONSTRAINT "whmcs_product_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_option_mappings" ADD CONSTRAINT "whmcs_option_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_addon_mappings" ADD CONSTRAINT "whmcs_addon_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f299e72..db1ef59 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -31,6 +31,7 @@ enum SoftwareFamily { enum GameServerStatus { DRAFT + ALLOCATING PROVISIONING INSTALLING STOPPED @@ -40,12 +41,95 @@ enum GameServerStatus { STOPPING BACKING_UP RESTORING + MIGRATING + SUSPENDED + MAINTENANCE UNKNOWN ERROR DELETING DELETED } +enum GameServerMemberRole { + OWNER + ADMIN + OPERATOR + DEVELOPER + VIEWER +} + +enum OrganizationMemberRole { + OWNER + ADMIN + MEMBER +} + +enum NotificationChannel { + IN_APP + EMAIL + WEBHOOK + DISCORD +} + +enum NotificationType { + SERVER_READY + SERVER_START_FAILED + SERVER_STOPPED + IDLE_STOP_WARNING + BACKUP_SUCCESS + BACKUP_FAILED + RESTORE_SUCCESS + RESTORE_FAILED + CREDITS_LOW + BILLING_ISSUE + INVITE + NODE_INCIDENT + MAINTENANCE + SECURITY + NEW_LOGIN + TWO_FA_CHANGED +} + +enum AbuseReportStatus { + OPEN + INVESTIGATING + RESOLVED + DISMISSED +} + +enum SupportTicketStatus { + OPEN + IN_PROGRESS + WAITING + RESOLVED + CLOSED +} + +enum SupportTicketPriority { + LOW + NORMAL + HIGH + URGENT +} + +enum ScheduleTaskType { + START + STOP + RESTART + BACKUP + CONSOLE_COMMAND + UPDATE_CHECK + MAINTENANCE +} + +enum CatalogProviderKind { + MODRINTH + CURSEFORGE + MOJANG + PAPERMC + CUSTOM +} + enum GameNodeStatus { ONLINE OFFLINE @@ -194,7 +278,18 @@ model User { totpCredential TotpCredential? creditWallet CreditWallet? whmcsClientLink WhmcsClientLink? + whmcsUserLinks WhmcsUserLink[] ssoTickets SsoTicket[] + organizationMembers OrganizationMember[] + organizationInvites OrganizationInvite[] @relation("OrganizationInviteInviter") + gameServerMembers GameServerMember[] + notifications Notification[] + notificationPreferences NotificationPreference[] + abuseReports AbuseReport[] + supportTickets SupportTicket[] + securityChangeLogs SecurityChangeLog[] + dataExportRequests DataExportRequest[] + accountDeletionRequests AccountDeletionRequest[] @@map("users") } @@ -361,6 +456,12 @@ model GameServer { dnsRecords ServerDnsRecord[] whmcsServiceLink WhmcsServiceLink? ssoTickets SsoTicket[] + members GameServerMember[] + worlds World[] + schedules Schedule[] + properties GameServerProperty[] + environmentVars GameServerEnvironmentVariable[] + secrets GameServerSecret[] @@index([userId]) @@index([nodeId]) @@ -430,6 +531,8 @@ model GameNode { heartbeats GameNodeHeartbeat[] allocations GameServerAllocation[] startQueueEntries ServerStartQueueEntry[] + capabilities GameNodeCapability[] + maintenanceWindows GameNodeMaintenanceWindow[] @@map("game_nodes") } @@ -743,7 +846,11 @@ model WhmcsInstallation { updatedAt DateTime @updatedAt @db.Timestamptz(3) clientLinks WhmcsClientLink[] + userLinks WhmcsUserLink[] serviceLinks WhmcsServiceLink[] + productMappings WhmcsProductMapping[] + optionMappings WhmcsOptionMapping[] + addonMappings WhmcsAddonMapping[] operations IntegrationOperation[] ssoTickets SsoTicket[] events IntegrationEvent[] @@ -907,3 +1014,562 @@ model WhmcsUsageExport { @@index([installationId, periodEnd]) @@map("whmcs_usage_exports") } + +model Organization { + id String @id @default(uuid()) + name String + slug String @unique + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + members OrganizationMember[] + invites OrganizationInvite[] + + @@map("organizations") +} + +model OrganizationMember { + id String @id @default(uuid()) + organizationId String + userId String + role OrganizationMemberRole @default(MEMBER) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([organizationId, userId]) + @@index([userId]) + @@map("organization_members") +} + +model OrganizationInvite { + id String @id @default(uuid()) + organizationId String + email String + role OrganizationMemberRole @default(MEMBER) + tokenHash String @unique + invitedById String + expiresAt DateTime @db.Timestamptz(3) + acceptedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + invitedBy User @relation("OrganizationInviteInviter", fields: [invitedById], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([email]) + @@map("organization_invites") +} + +model GameServerMember { + id String @id @default(uuid()) + serverId String + userId String + role GameServerMemberRole @default(VIEWER) + permissions Json? + invitedAt DateTime @default(now()) @db.Timestamptz(3) + acceptedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([serverId, userId]) + @@index([userId]) + @@map("game_server_members") +} + +model GameServerProperty { + id String @id @default(uuid()) + serverId String + key String + value String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([serverId, key]) + @@map("game_server_properties") +} + +model GameServerEnvironmentVariable { + id String @id @default(uuid()) + serverId String + key String + value String + isSecret Boolean @default(false) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([serverId, key]) + @@map("game_server_environment_variables") +} + +model GameServerSecret { + id String @id @default(uuid()) + serverId String + name String + valueCiphertext String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([serverId, name]) + @@map("game_server_secrets") +} + +model GameNodeCapability { + id String @id @default(uuid()) + nodeId String + key String + value String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + node GameNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@unique([nodeId, key]) + @@map("game_node_capabilities") +} + +model GameNodeMaintenanceWindow { + id String @id @default(uuid()) + nodeId String + startsAt DateTime @db.Timestamptz(3) + endsAt DateTime @db.Timestamptz(3) + drainFirst Boolean @default(true) + message String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + node GameNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@index([nodeId, startsAt]) + @@map("game_node_maintenance_windows") +} + +model MinecraftVersion { + id String @id @default(uuid()) + version String @unique + edition ServerEdition @default(JAVA) + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + softwareBuilds SoftwareBuild[] + + @@map("minecraft_versions") +} + +model SoftwareBuild { + id String @id @default(uuid()) + family SoftwareFamily + minecraftVersionId String + downloadUrl String? + sha256 String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + minecraftVersion MinecraftVersion @relation(fields: [minecraftVersionId], references: [id], onDelete: Cascade) + + @@unique([family, minecraftVersionId]) + @@map("software_builds") +} + +model RuntimeImage { + id String @id @default(uuid()) + name String @unique + imageRef String + digest String? + javaVersion String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + @@map("runtime_images") +} + +model CatalogProvider { + id String @id @default(uuid()) + kind CatalogProviderKind @unique + name String + isEnabled Boolean @default(false) + config Json? + lastSyncAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + projects CatalogProject[] + + @@map("catalog_providers") +} + +model CatalogProject { + id String @id @default(uuid()) + providerId String + externalId String + slug String + name String + projectType String + metadata Json? + syncedAt DateTime @default(now()) @db.Timestamptz(3) + + provider CatalogProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) + versions CatalogVersion[] + + @@unique([providerId, externalId]) + @@index([slug]) + @@map("catalog_projects") +} + +model CatalogVersion { + id String @id @default(uuid()) + projectId String + externalId String + versionNumber String + gameVersions Json? + loaders Json? + sha512 String? + downloadUrl String? + syncedAt DateTime @default(now()) @db.Timestamptz(3) + + project CatalogProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + dependencies CatalogDependency[] @relation("VersionDependencies") + + @@unique([projectId, externalId]) + @@map("catalog_versions") +} + +model CatalogDependency { + id String @id @default(uuid()) + versionId String + dependencyId String + dependencyType String + + version CatalogVersion @relation("VersionDependencies", fields: [versionId], references: [id], onDelete: Cascade) + + @@index([versionId]) + @@map("catalog_dependencies") +} + +model World { + id String @id @default(uuid()) + serverId String + name String + isActive Boolean @default(false) + sizeBytes BigInt? + hasLevelDat Boolean @default(false) + seed String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + archives WorldArchive[] + + @@unique([serverId, name]) + @@map("worlds") +} + +model WorldArchive { + id String @id @default(uuid()) + worldId String + s3Key String + sha256 String? + sizeBytes BigInt? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + world World @relation(fields: [worldId], references: [id], onDelete: Cascade) + + @@index([worldId]) + @@map("world_archives") +} + +model Schedule { + id String @id @default(uuid()) + serverId String + name String + timezone String @default("UTC") + cronExpr String + enabled Boolean @default(true) + nextRunAt DateTime? @db.Timestamptz(3) + lastRunAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + tasks ScheduledTask[] + + @@index([serverId]) + @@map("schedules") +} + +model ScheduledTask { + id String @id @default(uuid()) + scheduleId String + taskType ScheduleTaskType + payload Json? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + + @@index([scheduleId]) + @@map("scheduled_tasks") +} + +model Notification { + id String @id @default(uuid()) + userId String + type NotificationType + channel NotificationChannel @default(IN_APP) + title String + body String + metadata Json? + readAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, readAt]) + @@index([createdAt]) + @@map("notifications") +} + +model NotificationPreference { + id String @id @default(uuid()) + userId String + type NotificationType + channel NotificationChannel + enabled Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, type, channel]) + @@map("notification_preferences") +} + +model AbuseReport { + id String @id @default(uuid()) + reporterId String? + targetUserId String? + serverId String? + status AbuseReportStatus @default(OPEN) + reason String + details String? + resolution String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + reporter User? @relation(fields: [reporterId], references: [id], onDelete: SetNull) + + @@index([status]) + @@index([serverId]) + @@map("abuse_reports") +} + +model SupportTicket { + id String @id @default(uuid()) + userId String + serverId String? + subject String + status SupportTicketStatus @default(OPEN) + priority SupportTicketPriority @default(NORMAL) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages SupportTicketMessage[] + + @@index([userId]) + @@index([status]) + @@map("support_tickets") +} + +model SupportTicketMessage { + id String @id @default(uuid()) + ticketId String + authorId String? + isInternal Boolean @default(false) + body String + createdAt DateTime @default(now()) @db.Timestamptz(3) + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + + @@index([ticketId]) + @@map("support_ticket_messages") +} + +model Subscription { + id String @id @default(uuid()) + userId String + planId String + status String + startsAt DateTime @db.Timestamptz(3) + endsAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + @@index([userId]) + @@map("subscriptions") +} + +model Invoice { + id String @id @default(uuid()) + userId String + subscriptionId String? + amountCents Int + currency String @default("EUR") + status String + externalRef String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + payments Payment[] + + @@index([userId]) + @@map("invoices") +} + +model Payment { + id String @id @default(uuid()) + invoiceId String + amountCents Int + provider String + externalRef String? + status String + createdAt DateTime @default(now()) @db.Timestamptz(3) + + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + @@index([invoiceId]) + @@map("payments") +} + +model ResourceQuota { + id String @id @default(uuid()) + userId String? + planId String? + key String + limit Int + used Int @default(0) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + @@unique([userId, planId, key]) + @@map("resource_quotas") +} + +model SecurityChangeLog { + id String @id @default(uuid()) + userId String + action String + metadata Json? + ipAddress String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@map("security_change_logs") +} + +model DataExportRequest { + id String @id @default(uuid()) + userId String + status String @default("PENDING") + downloadUrl String? + expiresAt DateTime? @db.Timestamptz(3) + completedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("data_export_requests") +} + +model AccountDeletionRequest { + id String @id @default(uuid()) + userId String + status String @default("PENDING") + scheduledFor DateTime @db.Timestamptz(3) + completedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("account_deletion_requests") +} + +model WhmcsUserLink { + id String @id @default(uuid()) + installationId String + externalUserId String + externalClientId String + userId String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalUserId]) + @@index([userId]) + @@map("whmcs_user_links") +} + +model WhmcsProductMapping { + id String @id @default(uuid()) + installationId String + externalProductId String + planSlug String + defaultEdition ServerEdition @default(JAVA) + defaultSoftware SoftwareFamily @default(VANILLA) + defaultVersion String @default("1.21.1") + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalProductId]) + @@map("whmcs_product_mappings") +} + +model WhmcsOptionMapping { + id String @id @default(uuid()) + installationId String + externalOptionId String + externalValueId String + resourceKey String + normalizedValue String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalOptionId, externalValueId]) + @@map("whmcs_option_mappings") +} + +model WhmcsAddonMapping { + id String @id @default(uuid()) + installationId String + externalAddonId String + featureKey String + deltaValue String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalAddonId]) + @@map("whmcs_addon_mappings") +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index d59ee94..90ce5c5 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -38,6 +38,19 @@ export type { SystemSetting, AuditEvent, JobRecord, + Schedule, + ScheduledTask, + Notification, + NotificationPreference, + DataExportRequest, + AccountDeletionRequest, + Organization, + OrganizationMember, + OrganizationMemberRole, + OrganizationInvite, + SupportTicket, + SupportTicketMessage, + AbuseReport, } from '@prisma/client'; import { PrismaClient, Prisma } from '@prisma/client'; diff --git a/packages/email-templates/package.json b/packages/email-templates/package.json new file mode 100644 index 0000000..4b5eae8 --- /dev/null +++ b/packages/email-templates/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hexahost/email-templates", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts new file mode 100644 index 0000000..ee5b9af --- /dev/null +++ b/packages/email-templates/src/index.ts @@ -0,0 +1,102 @@ +export type EmailLocale = 'de' | 'en'; + +export interface EmailTemplateInput { + appName: string; + locale?: EmailLocale; +} + +export interface LinkEmailInput extends EmailTemplateInput { + url: string; +} + +const copy = { + de: { + verifySubject: (app: string) => `E-Mail bestätigen – ${app}`, + verifyHtml: (app: string, url: string) => + `

    Willkommen bei ${app}!

    E-Mail-Adresse bestätigen

    Der Link ist 24 Stunden gültig.

    `, + resetSubject: (app: string) => `Passwort zurücksetzen – ${app}`, + resetHtml: (app: string, url: string) => + `

    Du hast ein Passwort-Reset für ${app} angefordert.

    Neues Passwort setzen

    Falls du das nicht warst, ignoriere diese E-Mail.

    `, + serverReadySubject: (app: string, name: string) => `Server bereit – ${name} – ${app}`, + serverReadyHtml: (app: string, name: string) => + `

    Dein Server ${name} ist bereit.

    Öffne das Panel in ${app}, um zu starten.

    `, + backupSuccessSubject: (app: string, name: string) => `Backup erfolgreich – ${name}`, + backupSuccessHtml: (app: string, name: string) => + `

    Das Backup für ${name} wurde erfolgreich erstellt.

    `, + backupFailedSubject: (app: string, name: string) => `Backup fehlgeschlagen – ${name}`, + backupFailedHtml: (app: string, name: string) => + `

    Das Backup für ${name} ist fehlgeschlagen. Bitte prüfe das Panel in ${app}.

    `, + }, + en: { + verifySubject: (app: string) => `Verify your email – ${app}`, + verifyHtml: (app: string, url: string) => + `

    Welcome to ${app}!

    Verify your email address

    This link expires in 24 hours.

    `, + resetSubject: (app: string) => `Reset your password – ${app}`, + resetHtml: (app: string, url: string) => + `

    You requested a password reset for ${app}.

    Set a new password

    If you did not request this, ignore this email.

    `, + serverReadySubject: (app: string, name: string) => `Server ready – ${name} – ${app}`, + serverReadyHtml: (app: string, name: string) => + `

    Your server ${name} is ready.

    Open the panel in ${app} to get started.

    `, + backupSuccessSubject: (app: string, name: string) => `Backup successful – ${name}`, + backupSuccessHtml: (app: string, name: string) => + `

    The backup for ${name} completed successfully.

    `, + backupFailedSubject: (app: string, name: string) => `Backup failed – ${name}`, + backupFailedHtml: (app: string, name: string) => + `

    The backup for ${name} failed. Please check the panel in ${app}.

    `, + }, +} as const; + +function localeOf(input: EmailTemplateInput): EmailLocale { + return input.locale === 'de' ? 'de' : 'en'; +} + +export function renderVerifyEmail(input: LinkEmailInput): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.verifySubject(input.appName), + html: t.verifyHtml(input.appName, input.url), + }; +} + +export function renderResetEmail(input: LinkEmailInput): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.resetSubject(input.appName), + html: t.resetHtml(input.appName, input.url), + }; +} + +export function renderServerReadyEmail( + input: EmailTemplateInput & { serverName: string }, +): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.serverReadySubject(input.appName, input.serverName), + html: t.serverReadyHtml(input.appName, input.serverName), + }; +} + +export function renderBackupSuccessEmail( + input: EmailTemplateInput & { serverName: string }, +): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.backupSuccessSubject(input.appName, input.serverName), + html: t.backupSuccessHtml(input.appName, input.serverName), + }; +} + +export function renderBackupFailedEmail( + input: EmailTemplateInput & { serverName: string }, +): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.backupFailedSubject(input.appName, input.serverName), + html: t.backupFailedHtml(input.appName, input.serverName), + }; +} diff --git a/packages/email-templates/tsconfig.json b/packages/email-templates/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/email-templates/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/feature-flags/package.json b/packages/feature-flags/package.json new file mode 100644 index 0000000..f219fb0 --- /dev/null +++ b/packages/feature-flags/package.json @@ -0,0 +1,22 @@ +{ + "name": "@hexahost/feature-flags", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@hexahost/database": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/feature-flags/src/index.ts b/packages/feature-flags/src/index.ts new file mode 100644 index 0000000..b6ab6fe --- /dev/null +++ b/packages/feature-flags/src/index.ts @@ -0,0 +1,57 @@ +import type { PrismaClient } from '@hexahost/database'; + +export interface FeatureFlagSnapshot { + key: string; + enabled: boolean; + metadata: Record | null; +} + +export class FeatureFlagService { + private cache = new Map(); + private loadedAt = 0; + private readonly ttlMs: number; + + constructor( + private readonly prisma: PrismaClient, + ttlMs = 30_000, + ) { + this.ttlMs = ttlMs; + } + + async refresh(): Promise { + const flags = await this.prisma.featureFlag.findMany(); + this.cache.clear(); + for (const flag of flags) { + this.cache.set(flag.key, { + key: flag.key, + enabled: flag.enabled, + metadata: + flag.metadata && typeof flag.metadata === 'object' && !Array.isArray(flag.metadata) + ? (flag.metadata as Record) + : null, + }); + } + this.loadedAt = Date.now(); + } + + private async ensureLoaded(): Promise { + if (Date.now() - this.loadedAt > this.ttlMs || this.cache.size === 0) { + await this.refresh(); + } + } + + async isEnabled(key: string, defaultValue = false): Promise { + await this.ensureLoaded(); + return this.cache.get(key)?.enabled ?? defaultValue; + } + + async getFlag(key: string): Promise { + await this.ensureLoaded(); + return this.cache.get(key) ?? null; + } + + async listFlags(): Promise { + await this.ensureLoaded(); + return [...this.cache.values()]; + } +} diff --git a/packages/feature-flags/tsconfig.json b/packages/feature-flags/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/feature-flags/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/otel/package.json b/packages/otel/package.json new file mode 100644 index 0000000..911aa24 --- /dev/null +++ b/packages/otel/package.json @@ -0,0 +1,24 @@ +{ + "name": "@hexahost/otel", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.1", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-node": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts new file mode 100644 index 0000000..59618fc --- /dev/null +++ b/packages/otel/src/index.ts @@ -0,0 +1,37 @@ +import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { Resource } from '@opentelemetry/resources'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +let sdk: NodeSDK | null = null; + +export function initOpenTelemetry(serviceName: string): NodeSDK | null { + const endpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT']; + if (!endpoint) { + return null; + } + + if (process.env['OTEL_LOG_LEVEL'] === 'debug') { + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); + } + + sdk = new NodeSDK({ + resource: new Resource({ + [ATTR_SERVICE_NAME]: serviceName, + }), + traceExporter: new OTLPTraceExporter({ + url: endpoint.endsWith('/v1/traces') ? endpoint : `${endpoint.replace(/\/$/, '')}/v1/traces`, + }), + }); + + sdk.start(); + return sdk; +} + +export async function shutdownOpenTelemetry(): Promise { + if (sdk) { + await sdk.shutdown(); + sdk = null; + } +} diff --git a/packages/otel/tsconfig.json b/packages/otel/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/otel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/permissions/package.json b/packages/permissions/package.json new file mode 100644 index 0000000..10a4f1b --- /dev/null +++ b/packages/permissions/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hexahost/permissions", + "version": "0.0.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@hexahost/typescript-config": "workspace:*", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/permissions/src/index.ts b/packages/permissions/src/index.ts new file mode 100644 index 0000000..e4c39e1 --- /dev/null +++ b/packages/permissions/src/index.ts @@ -0,0 +1,134 @@ +export type ServerPermission = + | 'server.view' + | 'server.start' + | 'server.stop' + | 'server.restart' + | 'server.kill' + | 'server.console.view' + | 'server.console.command' + | 'server.files.view' + | 'server.files.edit' + | 'server.files.upload' + | 'server.files.download' + | 'server.files.delete' + | 'server.software.change' + | 'server.addons.manage' + | 'server.worlds.manage' + | 'server.players.manage' + | 'server.backups.view' + | 'server.backups.create' + | 'server.backups.restore' + | 'server.backups.delete' + | 'server.schedules.manage' + | 'server.members.manage' + | 'server.billing.view' + | 'server.delete'; + +export type ServerMemberRole = + | 'OWNER' + | 'ADMIN' + | 'OPERATOR' + | 'DEVELOPER' + | 'VIEWER'; + +const ROLE_PERMISSIONS: Record = { + OWNER: [ + 'server.view', + 'server.start', + 'server.stop', + 'server.restart', + 'server.kill', + 'server.console.view', + 'server.console.command', + 'server.files.view', + 'server.files.edit', + 'server.files.upload', + 'server.files.download', + 'server.files.delete', + 'server.software.change', + 'server.addons.manage', + 'server.worlds.manage', + 'server.players.manage', + 'server.backups.view', + 'server.backups.create', + 'server.backups.restore', + 'server.backups.delete', + 'server.schedules.manage', + 'server.members.manage', + 'server.billing.view', + 'server.delete', + ], + ADMIN: [ + 'server.view', + 'server.start', + 'server.stop', + 'server.restart', + 'server.kill', + 'server.console.view', + 'server.console.command', + 'server.files.view', + 'server.files.edit', + 'server.files.upload', + 'server.files.download', + 'server.files.delete', + 'server.software.change', + 'server.addons.manage', + 'server.worlds.manage', + 'server.players.manage', + 'server.backups.view', + 'server.backups.create', + 'server.backups.restore', + 'server.members.manage', + 'server.billing.view', + ], + OPERATOR: [ + 'server.view', + 'server.start', + 'server.stop', + 'server.restart', + 'server.console.view', + 'server.console.command', + 'server.players.manage', + 'server.backups.view', + ], + DEVELOPER: [ + 'server.view', + 'server.console.view', + 'server.console.command', + 'server.files.view', + 'server.files.edit', + 'server.files.upload', + 'server.files.download', + 'server.addons.manage', + 'server.worlds.manage', + 'server.backups.view', + 'server.backups.create', + ], + VIEWER: ['server.view', 'server.console.view', 'server.files.view', 'server.backups.view'], +}; + +export function permissionsForRole(role: ServerMemberRole): ServerPermission[] { + return ROLE_PERMISSIONS[role]; +} + +export function hasPermission( + role: ServerMemberRole, + permission: ServerPermission, + overrides?: ServerPermission[] | null, +): boolean { + if (overrides?.includes(permission)) { + return true; + } + + return ROLE_PERMISSIONS[role].includes(permission); +} + +export function assertPermission( + role: ServerMemberRole, + permission: ServerPermission, + overrides?: ServerPermission[] | null, +): void { + if (!hasPermission(role, permission, overrides)) { + throw new Error(`Missing permission: ${permission}`); + } +} diff --git a/packages/permissions/src/permissions.test.ts b/packages/permissions/src/permissions.test.ts new file mode 100644 index 0000000..1a70111 --- /dev/null +++ b/packages/permissions/src/permissions.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { hasPermission, permissionsForRole } from './index'; + +describe('permissions', () => { + it('grants owner full access', () => { + expect(hasPermission('OWNER', 'server.delete')).toBe(true); + expect(permissionsForRole('OWNER')).toContain('server.kill'); + }); + + it('restricts viewer to read-only', () => { + expect(hasPermission('VIEWER', 'server.start')).toBe(false); + expect(hasPermission('VIEWER', 'server.view')).toBe(true); + }); + + it('honors explicit overrides', () => { + expect(hasPermission('VIEWER', 'server.start', ['server.start'])).toBe(true); + }); +}); diff --git a/packages/permissions/tsconfig.json b/packages/permissions/tsconfig.json new file mode 100644 index 0000000..a1b090e --- /dev/null +++ b/packages/permissions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@hexahost/typescript-config/library.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/server-state/package.json b/packages/server-state/package.json new file mode 100644 index 0000000..55d187b --- /dev/null +++ b/packages/server-state/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hexahost/server-state", + "version": "0.0.0", + "private": true, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@hexahost/typescript-config": "workspace:*", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/server-state/src/index.ts b/packages/server-state/src/index.ts new file mode 100644 index 0000000..9db5e36 --- /dev/null +++ b/packages/server-state/src/index.ts @@ -0,0 +1,107 @@ +export type GameServerStatus = + | 'DRAFT' + | 'ALLOCATING' + | 'PROVISIONING' + | 'INSTALLING' + | 'STOPPED' + | 'QUEUED' + | 'STARTING' + | 'RUNNING' + | 'STOPPING' + | 'BACKING_UP' + | 'RESTORING' + | 'MIGRATING' + | 'SUSPENDED' + | 'MAINTENANCE' + | 'UNKNOWN' + | 'ERROR' + | 'DELETING' + | 'DELETED'; + +export const ALLOWED_TRANSITIONS: Record = { + DRAFT: ['ALLOCATING', 'PROVISIONING', 'DELETING'], + ALLOCATING: ['PROVISIONING', 'ERROR', 'DELETING'], + PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'], + INSTALLING: ['STOPPED', 'ERROR', 'DELETING'], + STOPPED: [ + 'STARTING', + 'QUEUED', + 'PROVISIONING', + 'BACKING_UP', + 'RESTORING', + 'MIGRATING', + 'SUSPENDED', + 'MAINTENANCE', + 'DELETING', + ], + QUEUED: ['STARTING', 'STOPPED', 'DELETING'], + STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], + RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'UNKNOWN', 'DELETING'], + STOPPING: ['STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], + BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'], + RESTORING: ['STOPPED', 'ERROR'], + MIGRATING: ['STOPPED', 'ERROR'], + SUSPENDED: ['STOPPED', 'DELETING'], + MAINTENANCE: ['STOPPED', 'DELETING'], + UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'], + ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'], + DELETING: ['DELETED'], + DELETED: [], +}; + +export const IN_PROGRESS_STATUSES: GameServerStatus[] = [ + 'ALLOCATING', + 'PROVISIONING', + 'INSTALLING', + 'STARTING', + 'STOPPING', + 'BACKING_UP', + 'RESTORING', + 'MIGRATING', + 'DELETING', +]; + +export function assertTransition( + fromStatus: GameServerStatus, + toStatus: GameServerStatus, +): void { + const allowed = ALLOWED_TRANSITIONS[fromStatus]; + + if (!allowed.includes(toStatus)) { + throw new Error(`Invalid state transition from ${fromStatus} to ${toStatus}`); + } +} + +export function isInProgress(status: GameServerStatus): boolean { + return IN_PROGRESS_STATUSES.includes(status); +} + +export function resolveStartTarget(status: GameServerStatus): GameServerStatus { + switch (status) { + case 'DRAFT': + case 'ERROR': + return 'PROVISIONING'; + case 'STOPPED': + case 'UNKNOWN': + case 'QUEUED': + return 'STARTING'; + default: + throw new Error(`Cannot start server while in status ${status}`); + } +} + +export function resolveStopTarget(status: GameServerStatus): GameServerStatus { + if (status !== 'RUNNING') { + throw new Error(`Cannot stop server while in status ${status}`); + } + + return 'STOPPING'; +} + +export function resolveKillTarget(status: GameServerStatus): GameServerStatus { + if (status !== 'RUNNING' && status !== 'STARTING' && status !== 'STOPPING') { + throw new Error(`Cannot kill server while in status ${status}`); + } + + return 'STOPPED'; +} diff --git a/packages/server-state/src/state.test.ts b/packages/server-state/src/state.test.ts new file mode 100644 index 0000000..303949a --- /dev/null +++ b/packages/server-state/src/state.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertTransition, + isInProgress, + resolveKillTarget, + resolveStartTarget, +} from './index'; + +describe('server state machine', () => { + it('allows draft to provisioning', () => { + expect(() => assertTransition('DRAFT', 'PROVISIONING')).not.toThrow(); + }); + + it('rejects invalid transition', () => { + expect(() => assertTransition('DELETED', 'RUNNING')).toThrow(); + }); + + it('detects in-progress statuses', () => { + expect(isInProgress('STARTING')).toBe(true); + expect(isInProgress('STOPPED')).toBe(false); + }); + + it('resolves start and kill targets', () => { + expect(resolveStartTarget('STOPPED')).toBe('STARTING'); + expect(resolveKillTarget('RUNNING')).toBe('STOPPED'); + }); +}); diff --git a/packages/server-state/tsconfig.json b/packages/server-state/tsconfig.json new file mode 100644 index 0000000..a1b090e --- /dev/null +++ b/packages/server-state/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@hexahost/typescript-config/library.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index c2484c3..e46401c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,6 +12,10 @@ "typecheck": "tsc --noEmit", "lint": "tsc --noEmit" }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6" + }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" diff --git a/packages/ui/src/components/Dialog.tsx b/packages/ui/src/components/Dialog.tsx new file mode 100644 index 0000000..885ea1c --- /dev/null +++ b/packages/ui/src/components/Dialog.tsx @@ -0,0 +1,112 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { forwardRef, type ComponentPropsWithoutRef, type ElementRef, type HTMLAttributes } from "react"; +import { cn } from "../lib/cn"; + +export const Dialog = DialogPrimitive.Root; +export const DialogTrigger = DialogPrimitive.Trigger; +export const DialogPortal = DialogPrimitive.Portal; +export const DialogClose = DialogPrimitive.Close; + +export const DialogOverlay = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +export const DialogContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + + + +)); + +DialogContent.displayName = DialogPrimitive.Content.displayName; + +export interface DialogHeaderProps extends HTMLAttributes {} + +export const DialogHeader = forwardRef( + ({ className, ...props }, ref) => ( +
    + ), +); + +DialogHeader.displayName = "DialogHeader"; + +export interface DialogFooterProps extends HTMLAttributes {} + +export const DialogFooter = forwardRef( + ({ className, ...props }, ref) => ( +
    + ), +); + +DialogFooter.displayName = "DialogFooter"; + +export const DialogTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +export const DialogDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogDescription.displayName = DialogPrimitive.Description.displayName; diff --git a/packages/ui/src/components/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu.tsx new file mode 100644 index 0000000..546d84f --- /dev/null +++ b/packages/ui/src/components/DropdownMenu.tsx @@ -0,0 +1,199 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { forwardRef, type ComponentPropsWithoutRef, type ElementRef, type HTMLAttributes } from "react"; +import { cn } from "../lib/cn"; + +export const DropdownMenu = DropdownMenuPrimitive.Root; +export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +export const DropdownMenuGroup = DropdownMenuPrimitive.Group; +export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +export const DropdownMenuSub = DropdownMenuPrimitive.Sub; +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +export const DropdownMenuSubTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); + +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +export const DropdownMenuSubContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +export const DropdownMenuContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); + +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +export const DropdownMenuItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); + +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +export const DropdownMenuCheckboxItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); + +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +export const DropdownMenuRadioItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + {children} + +)); + +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +export interface DropdownMenuLabelProps + extends ComponentPropsWithoutRef { + inset?: boolean; +} + +export const DropdownMenuLabel = forwardRef< + ElementRef, + DropdownMenuLabelProps +>(({ className, inset, ...props }, ref) => ( + +)); + +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +export const DropdownMenuSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export interface DropdownMenuShortcutProps extends HTMLAttributes {} + +export const DropdownMenuShortcut = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2730365..7de7ef9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,4 +13,37 @@ export { type CardProps, type CardTitleProps, } from "./components/Card"; +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, + type DialogFooterProps, + type DialogHeaderProps, +} from "./components/Dialog"; +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + type DropdownMenuLabelProps, + type DropdownMenuShortcutProps, +} from "./components/DropdownMenu"; export { cn } from "./lib/cn"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5b0776..63d43c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: apps/api: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.750.0 + version: 3.1075.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -44,15 +47,27 @@ importers: '@hexahost/dns': specifier: workspace:* version: link:../../packages/dns + '@hexahost/feature-flags': + specifier: workspace:* + version: link:../../packages/feature-flags '@hexahost/integration-auth': specifier: workspace:* version: link:../../packages/integration-auth '@hexahost/metering': specifier: workspace:* version: link:../../packages/metering + '@hexahost/otel': + specifier: workspace:* + version: link:../../packages/otel + '@hexahost/permissions': + specifier: workspace:* + version: link:../../packages/permissions '@hexahost/scheduler': specifier: workspace:* version: link:../../packages/scheduler + '@hexahost/server-state': + specifier: workspace:* + version: link:../../packages/server-state '@hexahost/storage': specifier: workspace:* version: link:../../packages/storage @@ -74,6 +89,9 @@ importers: bullmq: specifier: ^5.34.8 version: 5.79.1 + cron-parser: + specifier: ^5.0.4 + version: 5.6.1 fastify: specifier: ^5.2.1 version: 5.8.5 @@ -124,6 +142,27 @@ importers: specifier: ^5.7.3 version: 5.9.3 + apps/docs: + devDependencies: + serve: + specifier: ^14.2.4 + version: 14.2.6 + + apps/e2e: + devDependencies: + '@hexahost/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@playwright/test': + specifier: ^1.51.1 + version: 1.61.1 + '@types/node': + specifier: ^22.13.10 + version: 22.20.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + apps/web: dependencies: '@hexahost/ui': @@ -137,10 +176,10 @@ importers: version: 5.101.1(react@19.2.7) next: specifier: ^15.2.3 - version: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-intl: specifier: ^4.0.2 - version: 4.13.0(next@15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3) + version: 4.13.0(next@15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -187,6 +226,9 @@ importers: apps/worker: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.758.0 + version: 3.1075.0 '@hexahost/catalog': specifier: workspace:* version: link:../../packages/catalog @@ -199,18 +241,27 @@ importers: '@hexahost/dns': specifier: workspace:* version: link:../../packages/dns + '@hexahost/email-templates': + specifier: workspace:* + version: link:../../packages/email-templates '@hexahost/metering': specifier: workspace:* version: link:../../packages/metering '@hexahost/scheduler': specifier: workspace:* version: link:../../packages/scheduler + '@hexahost/server-state': + specifier: workspace:* + version: link:../../packages/server-state '@hexahost/storage': specifier: workspace:* version: link:../../packages/storage bullmq: specifier: ^5.34.10 version: 5.79.1 + cron-parser: + specifier: ^5.0.4 + version: 5.6.1 ioredis: specifier: ^5.4.2 version: 5.10.1 @@ -356,8 +407,27 @@ importers: specifier: ^3.0.8 version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/email-templates: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/eslint-config: {} + packages/feature-flags: + dependencies: + '@hexahost/database': + specifier: workspace:* + version: link:../database + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/integration-auth: devDependencies: '@hexahost/typescript-config': @@ -388,6 +458,40 @@ importers: specifier: ^3.0.8 version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/otel: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.57.1 + version: 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^1.30.1 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: ^0.57.1 + version: 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: ^1.30.0 + version: 1.41.1 + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/permissions: + devDependencies: + '@hexahost/typescript-config': + specifier: workspace:* + version: link:../typescript-config + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/scheduler: devDependencies: '@hexahost/typescript-config': @@ -403,6 +507,18 @@ importers: specifier: ^3.0.8 version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/server-state: + devDependencies: + '@hexahost/typescript-config': + specifier: workspace:* + version: link:../typescript-config + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/storage: dependencies: '@aws-sdk/client-s3': @@ -432,6 +548,12 @@ importers: packages/ui: dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.6 + version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.6 + version: 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: ^18.0.0 || ^19.0.0 version: 19.2.7 @@ -853,6 +975,21 @@ packages: '@fastify/websocket@11.2.0': resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@formatjs/fast-memoize@3.1.6': resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==} @@ -865,6 +1002,15 @@ packages: '@formatjs/intl-localematcher@0.8.10': resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==} + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@hookform/resolvers@4.1.3': resolution: {integrity: sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==} peerDependencies: @@ -1193,6 +1339,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -1503,6 +1652,172 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@1.30.1': + resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.57.2': + resolution: {integrity: sha512-eovEy10n3umjKJl2Ey6TLzikPE+W4cUQ4gCwgGP1RqzTGtgDra0WjIqdy29ohiUKfvmbiL3MndZww58xfIvyFw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.57.2': + resolution: {integrity: sha512-0rygmvLcehBRp56NQVLSleJ5ITTduq/QfU7obOkyWgPpFHulwpw2LYTqNIz5TczKZuy5YY+5D3SDnXZL1tXImg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.57.2': + resolution: {integrity: sha512-ta0ithCin0F8lu9eOf4lEz9YAScecezCHkMMyDkvd9S7AnZNX5ikUmC5EQOQADU+oCcgo/qkQIaKcZvQ0TYKDw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2': + resolution: {integrity: sha512-r70B8yKR41F0EC443b5CGB4rUaOMm99I5N75QQt6sHKxYDzSEc6gm48Diz1CI1biwa5tDPznpylTrywO/pT7qw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.57.2': + resolution: {integrity: sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.57.2': + resolution: {integrity: sha512-HX068Q2eNs38uf7RIkNN9Hl4Ynl+3lP0++KELkXMCpsCbFO03+0XNNZ1SkwxPlP9jrhQahsMPMkzNXpq3fKsnw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.57.2': + resolution: {integrity: sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2': + resolution: {integrity: sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.57.2': + resolution: {integrity: sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.57.2': + resolution: {integrity: sha512-awDdNRMIwDvUtoRYxRhja5QYH6+McBLtoz1q9BeEsskhZcrGmH/V1fWpGx8n+Rc+542e8pJA6y+aullbIzQmlw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@1.30.1': + resolution: {integrity: sha512-6S2QIMJahIquvFaaxmcwpvQQRD/YFaMTNoIxrfPIPOeITN+a8lfEcPDxNxn8JDAaxkg+4EnXhz8upVDYenoQjA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation@0.57.2': + resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.57.2': + resolution: {integrity: sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2': + resolution: {integrity: sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.57.2': + resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@1.30.1': + resolution: {integrity: sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@1.30.1': + resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.57.2': + resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.30.1': + resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-node@0.57.2': + resolution: {integrity: sha512-8BaeqZyN5sTuPBtAoY+UtKwXBdqyuRKmekN5bFzAO40CgbGzAxfTpiL3PBerT7rhZ7p2nBdq7FaMv/tBQgHE4A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@1.30.1': + resolution: {integrity: sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@otplib/core@12.0.1': resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} @@ -1606,6 +1921,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@prisma/client@6.19.3': resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} engines: {node: '>=18.18'} @@ -1636,6 +1956,303 @@ packages: '@prisma/get-platform@6.19.3': resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-arrow@1.1.11': + resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.11': + resolution: {integrity: sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.18': + resolution: {integrity: sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.14': + resolution: {integrity: sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.19': + resolution: {integrity: sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.11': + resolution: {integrity: sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.19': + resolution: {integrity: sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.2': + resolution: {integrity: sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.13': + resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.7': + resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.14': + resolution: {integrity: sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -1988,6 +2605,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/shimmer@1.2.0': + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2240,9 +2860,17 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zeit/schemas@2.36.0': + resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -2294,6 +2922,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2302,10 +2933,18 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2317,12 +2956,19 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2424,6 +3070,10 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -2455,6 +3105,14 @@ packages: redis: optional: true + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2487,6 +3145,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} @@ -2494,10 +3156,18 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -2523,6 +3193,13 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2542,6 +3219,14 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -2571,6 +3256,14 @@ packages: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2581,6 +3274,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -2602,6 +3299,10 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cron-parser@5.6.1: + resolution: {integrity: sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2632,6 +3333,14 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2653,6 +3362,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2697,6 +3410,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -2718,6 +3434,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} @@ -2942,6 +3661,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -3061,6 +3784,11 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3088,10 +3816,18 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -3173,6 +3909,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3195,6 +3935,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3202,6 +3945,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3259,6 +4005,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-document.all@1.0.0: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} @@ -3303,6 +4054,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3315,6 +4070,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3343,6 +4102,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3464,6 +4227,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -3480,6 +4246,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -3517,10 +4286,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -3544,6 +4321,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3574,6 +4357,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3662,6 +4449,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + nypm@0.6.7: resolution: {integrity: sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ==} engines: {node: '>=18'} @@ -3710,6 +4501,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3755,6 +4550,9 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3766,6 +4564,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -3825,6 +4626,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -3910,6 +4721,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3926,9 +4741,17 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -3943,6 +4766,36 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -3985,10 +4838,25 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + + registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4089,6 +4957,14 @@ packages: engines: {node: '>=10'} hasBin: true + serve-handler@6.1.7: + resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} + + serve@14.2.6: + resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==} + engines: {node: '>= 14'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -4119,6 +4995,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -4194,6 +5073,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4224,10 +5107,22 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4429,6 +5324,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4478,17 +5377,44 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-intl@4.13.0: resolution: {integrity: sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==} peerDependencies: react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4613,6 +5539,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4621,6 +5551,14 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4636,10 +5574,18 @@ packages: utf-8-validate: optional: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5171,6 +6117,23 @@ snapshots: - bufferutil - utf-8-validate + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + '@formatjs/fast-memoize@3.1.6': {} '@formatjs/icu-messageformat-parser@3.5.12': @@ -5183,6 +6146,18 @@ snapshots: dependencies: '@formatjs/fast-memoize': 3.1.6 + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + '@hookform/resolvers@4.1.3(react-hook-form@7.80.0(react@19.2.7))': dependencies: '@standard-schema/utils': 0.3.0 @@ -5464,6 +6439,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@lukeed/csprng@1.1.0': {} '@lukeed/ms@2.0.2': {} @@ -5721,6 +6698,239 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.8.5 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + protobufjs: 7.6.5 + + '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + semver: 7.8.5 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@otplib/core@12.0.1': {} '@otplib/plugin-crypto@12.0.1': @@ -5806,6 +7016,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.3(typescript@5.9.3) @@ -5841,6 +7055,280 @@ snapshots: dependencies: '@prisma/debug': 6.19.3 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/rect@1.1.2': {} + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -6118,6 +7606,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/shimmer@1.2.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.0 @@ -6405,8 +7895,14 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zeit/schemas@2.36.0': {} + abstract-logging@2.0.1: {} + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn-import-phases@1.0.4(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -6459,14 +7955,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + ansis@4.2.0: {} any-promise@1.3.0: {} @@ -6476,10 +7980,16 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + arch@2.2.0: {} + arg@5.0.2: {} argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -6599,6 +8109,17 @@ snapshots: bowser@2.14.1: {} + boxen@7.0.0: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -6638,6 +8159,10 @@ snapshots: transitivePeerDependencies: - supports-color + bytes@3.0.0: {} + + bytes@3.1.2: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -6676,6 +8201,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@7.0.1: {} + caniuse-lite@1.0.30001799: {} chai@5.3.3: @@ -6686,11 +8213,17 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.0.1: {} + chardet@2.2.0: {} check-error@2.1.3: {} @@ -6719,6 +8252,10 @@ snapshots: citty@0.2.2: {} + cjs-module-lexer@1.4.3: {} + + cli-boxes@3.0.0: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -6735,6 +8272,18 @@ snapshots: client-only@0.0.1: {} + clipboardy@3.0.0: + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: {} cluster-key-slot@1.1.2: {} @@ -6756,12 +8305,30 @@ snapshots: array-timsort: 1.0.3 esprima: 4.0.1 + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} confbox@0.2.4: {} consola@3.4.2: {} + content-disposition@0.5.2: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -6781,6 +8348,10 @@ snapshots: dependencies: luxon: 3.7.2 + cron-parser@5.6.1: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6813,6 +8384,10 @@ snapshots: dateformat@4.6.3: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6823,6 +8398,8 @@ snapshots: deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -6857,6 +8434,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -6880,6 +8459,8 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} + effect@3.21.0: dependencies: '@standard-schema/spec': 1.1.0 @@ -7269,6 +8850,18 @@ snapshots: events@3.3.0: {} + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.4.0: {} exsolve@1.1.0: {} @@ -7421,6 +9014,9 @@ snapshots: fs-monkey@1.1.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7457,11 +9053,15 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -7549,6 +9149,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + human-signals@2.1.0: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -7568,10 +9170,19 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} inherits@2.0.4: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -7649,6 +9260,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-document.all@1.0.0: dependencies: call-bound: 1.0.4 @@ -7686,6 +9299,8 @@ snapshots: is-number@7.0.0: {} + is-port-reachable@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -7699,6 +9314,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -7727,6 +9344,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7838,6 +9459,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} @@ -7851,6 +9474,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -7880,8 +9505,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.33.0: {} + mime-db@1.54.0: {} + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + mime@3.0.0: {} mimic-fn@2.1.0: {} @@ -7898,6 +9529,10 @@ snapshots: minipass@7.1.3: {} + module-details-from-path@1.0.4: {} + + ms@2.0.0: {} + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -7930,6 +9565,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.4: {} + negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -7943,14 +9580,14 @@ snapshots: next-intl-swc-plugin-extractor@4.13.0: {} - next-intl@4.13.0(next@15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3): + next-intl@4.13.0(next@15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.8.10 '@parcel/watcher': 2.5.6 '@swc/core': 1.15.43 icu-minify: 4.13.0 negotiator: 1.0.0 - next: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-intl-swc-plugin-extractor: 4.13.0 po-parser: 2.1.1 react: 19.2.7 @@ -7965,7 +9602,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 15.5.19 '@swc/helpers': 0.5.15 @@ -7983,6 +9620,8 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.19 '@next/swc-win32-arm64-msvc': 15.5.19 '@next/swc-win32-x64-msvc': 15.5.19 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -8016,6 +9655,10 @@ snapshots: normalize-path@3.0.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + nypm@0.6.7: dependencies: citty: 0.2.2 @@ -8070,6 +9713,8 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8134,6 +9779,8 @@ snapshots: path-exists@4.0.0: {} + path-is-inside@1.0.2: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -8143,6 +9790,8 @@ snapshots: lru-cache: 11.5.1 minipass: 7.1.3 + path-to-regexp@3.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -8216,6 +9865,14 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} po-parser@2.1.1: {} @@ -8287,6 +9944,20 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.20.0 + long: 5.3.2 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8300,11 +9971,20 @@ snapshots: quick-format-unescaped@4.0.4: {} + range-parser@1.2.0: {} + rc9@2.1.2: dependencies: defu: 6.1.7 destr: 2.0.5 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -8316,6 +9996,33 @@ snapshots: react-is@16.13.1: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + react@19.2.7: {} read-cache@1.0.0: @@ -8364,8 +10071,27 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + registry-auth-token@3.3.2: + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + + registry-url@3.1.0: + dependencies: + rc: 1.2.8 + + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8492,6 +10218,32 @@ snapshots: semver@7.8.5: {} + serve-handler@6.1.7: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.5 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + + serve@14.2.6: + dependencies: + '@zeit/schemas': 2.36.0 + ajv: 8.18.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.8.1 + is-port-reachable: 4.0.0 + serve-handler: 6.1.7 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -8556,6 +10308,8 @@ snapshots: shebang-regex@3.0.0: {} + shimmer@1.2.1: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -8630,6 +10384,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -8689,8 +10449,16 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -8873,6 +10641,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@2.19.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -8958,10 +10728,22 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-check@1.5.4: + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + uri-js@4.4.1: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + use-intl@4.13.0(react@19.2.7): dependencies: '@formatjs/fast-memoize': 3.1.6 @@ -8970,8 +10752,18 @@ snapshots: intl-messageformat: 11.2.9 react: 19.2.7 + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + util-deprecate@1.0.2: {} + vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4): dependencies: cac: 6.7.14 @@ -9151,6 +10943,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -9159,12 +10955,36 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.21.0: {} + y18n@5.0.8: {} + yargs-parser@21.1.1: {} + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {}