Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions.
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 12s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
TheOnlyMace
2026-07-05 18:39:53 +02:00
parent bf36cb3159
commit 50cd4b3ffd
225 changed files with 17824 additions and 436 deletions

View File

@@ -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",

View File

@@ -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<AbuseService['create']>[1],
);
}
}

View File

@@ -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 {}

View File

@@ -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<AbuseReport> {
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<AbuseReportListResponse> {
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<AbuseReport> {
const report = await this.prisma.abuseReport.update({
where: { id: reportId },
data: {
status: input.status,
resolution: input.resolution,
},
});
return mapReport(report);
}
}

View File

@@ -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<AbuseService['updateAdmin']>[1],
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {}

View File

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

View File

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

View File

@@ -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 {}

View File

@@ -712,4 +712,77 @@ export class AuthService {
return false;
}
async completeOidcLogin(code: string, reply: FastifyReply): Promise<void> {
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);
}
}

View File

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

View File

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

View File

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

View File

@@ -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<FastifyRequest>();
this.csrf.validate(request);
return true;
}
}

View File

@@ -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 {}

View File

@@ -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<string, string | undefined>;
return cookies[CSRF_COOKIE] ?? null;
}
private sign(token: string): string {
return createHmac('sha256', this.config.SESSION_SECRET)
.update(token)
.digest('base64url');
}
}

View File

@@ -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<Observable<unknown>> {
const request = context.switchToHttp().getRequest<{
method?: string;
headers?: Record<string, string | string[] | undefined>;
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, unknown>): 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;
}

View File

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

View File

@@ -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 {}

View File

@@ -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<number> {
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<number> {
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<void> {
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<void> {
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<Record<string, number>>((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(),
};
}
}

View File

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

View File

@@ -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 {}

View File

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

View File

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

View File

@@ -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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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<WhmcsServiceResponse> {
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;
}
}

View File

@@ -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<void> {
const config = validateConfig();
initOpenTelemetry('hexahost-api');
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,

View File

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

View File

@@ -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<NotificationsService['updatePreferences']>[1],
);
}
}

View File

@@ -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 {}

View File

@@ -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<NotificationListResponse> {
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<string, unknown>;
},
): Promise<void> {
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 },
);
}
}
}

View File

@@ -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<OrganizationsService['create']>[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<OrganizationsService['inviteMember']>[2],
);
}
}

View File

@@ -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 {}

View File

@@ -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<OrganizationListResponse> {
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<Organization> {
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<OrganizationMemberListResponse> {
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<OrganizationInviteResponse | { memberId: string; userId: string; role: string }> {
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<OrganizationMember> {
const membership = await this.prisma.organizationMember.findUnique({
where: {
organizationId_userId: { organizationId, userId },
},
});
if (!membership) {
throw new NotFoundException('Organization not found');
}
return membership;
}
}

View File

@@ -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<FilesService['writeFile']>[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<FilesService['uploadFile']>[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<FilesService['archiveFiles']>[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<FilesService['unarchiveFile']>[2],
);
}
}

View File

@@ -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<GameServer['status']>(['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<FileContent> {
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(

View File

@@ -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<typeof inviteSchema>;
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 };
}
}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MembersController } from './members.controller';
@Module({
controllers: [MembersController],
})
export class MembersModule {}

View File

@@ -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<PlayersService['addWhitelist']>[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<PlayersService['removeWhitelist']>[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<PlayersService['addOperator']>[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<PlayersService['removeOperator']>[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<PlayersService['addBan']>[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 },
);
}
}

View File

@@ -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<Array<{ uuid: string; name: string }>>(
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<Array<{ uuid: string; name: string }>>(
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<Array<{ uuid: string; name: string }>>(
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<Array<{ name: string }>>(
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<T>(
userId: string,
serverId: string,
path: string,
data: T,
): Promise<void> {
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<void> {
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,
);
}
}

View File

@@ -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<SchedulesService['create']>[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<SchedulesService['update']>[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);
}
}

View File

@@ -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 {}

View File

@@ -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<string, unknown> | null) ?? null,
})),
};
}
@Injectable()
export class SchedulesService {
constructor(private readonly prisma: PrismaService) {}
async list(userId: string, serverId: string): Promise<ScheduleListResponse> {
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');
}
}
}

View File

@@ -1,34 +1,15 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { GameServer } from '@hexahost/database';
type GameServerStatus = GameServer['status'];
const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
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;
}
}

View File

@@ -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<ServersService['updateServer']>[2],
);
}
@Get(':id/start-queue')
@ApiOperation({ summary: 'Get start queue status for a server' })
startQueue(

View File

@@ -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,
],

View File

@@ -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<ServerResponse> {
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<ServerActionResponse> {
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<ServerActionResponse> {
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<ServerActionResponse> {
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<ServerResponse> {
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<string, unknown>,
@@ -422,6 +602,15 @@ export class ServersService {
}
}
private async assertMaintenanceAllowed(action: string): Promise<void> {
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,

View File

@@ -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<GameServer> {
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<ServerAccessContext> {
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,
};
}

View File

@@ -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<ServersService['reinstallServer']>[2],
);
}
}

View File

@@ -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 {}

View File

@@ -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<SupportService['create']>[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<SupportService['addMessage']>[2],
);
}
}

View File

@@ -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 {}

View File

@@ -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<SupportTicket> {
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<SupportTicketListResponse> {
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<SupportTicket> {
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<SupportTicket> {
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);
}
}