Phase7
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 8s
CI / Go — edge-gateway build (push) Successful in 14s

This commit is contained in:
smueller
2026-06-26 13:20:55 +02:00
parent 49a98ee31d
commit ab21f53cdd
86 changed files with 1207 additions and 63 deletions

View File

@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { BillingModule } from './billing/billing.module';
import { CatalogModule } from './catalog/catalog.module';
import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module';
@@ -42,6 +43,7 @@ import { LoggerModule } from 'nestjs-pino';
HealthModule,
AuthModule,
CatalogModule,
BillingModule,
ServersModule,
NodesModule,
AdminModule,

View File

@@ -5,6 +5,7 @@ import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { AppConfigModule } from '../config/app-config.module';
import { BillingModule } from '../billing/billing.module';
import { AuditService } from './audit.service';
import { AuthController } from './auth.controller';
@@ -18,7 +19,7 @@ import { MeController } from './me.controller';
import { SessionService } from './session.service';
@Module({
imports: [AppConfigModule],
imports: [AppConfigModule, BillingModule],
controllers: [AuthController, MeController],
providers: [
AuthService,

View File

@@ -44,6 +44,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from './audit.service';
import { SessionService } from './session.service';
import { BillingService } from '../billing/billing.service';
const LOCKOUT_ATTEMPTS = 5;
const LOCKOUT_DURATION_MS = 15 * 60 * 1000;
@@ -74,6 +75,7 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly sessionService: SessionService,
private readonly auditService: AuditService,
private readonly billingService: BillingService,
@Inject(APP_CONFIG) private readonly config: AppConfig,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue,
@@ -123,6 +125,8 @@ export class AuthService {
return created;
});
await this.billingService.ensureWalletForUser(user.id);
await this.createEmailVerificationToken(user.id, user.email);
await this.auditService.record({

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { SessionGuard } from '../auth/guards/session.guard';
import { BillingService } from './billing.service';
@ApiTags('account')
@Controller('account')
@UseGuards(SessionGuard)
export class BillingController {
constructor(private readonly billingService: BillingService) {}
@Get('wallet')
@ApiOperation({ summary: 'Get credit wallet balance and recent transactions' })
getWallet(@CurrentUser() user: { id: string }): Promise<WalletResponse> {
return this.billingService.getWallet(user.id);
}
@Get('usage')
@ApiOperation({ summary: 'List recent usage records' })
listUsage(
@CurrentUser() user: { id: string },
@Query('limit') limit?: string,
): Promise<UsageListResponse> {
const parsed = limit ? Number.parseInt(limit, 10) : 20;
return this.billingService.listUsage(user.id, Number.isFinite(parsed) ? parsed : 20);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { BillingController } from './billing.controller';
import { BillingService } from './billing.service';
@Module({
imports: [PrismaModule],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,190 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
async ensureWalletForUser(userId: string, grantCredits = 0): Promise<void> {
const existing = await this.prisma.creditWallet.findUnique({
where: { userId },
});
if (existing) {
return;
}
const freePlan = await this.prisma.plan.findFirst({
where: { slug: 'free', isActive: true },
});
const initialGrant = grantCredits || freePlan?.monthlyCreditGrant || 0;
await this.prisma.$transaction(async (tx) => {
const wallet = await tx.creditWallet.create({
data: {
userId,
balance: initialGrant,
},
});
if (initialGrant > 0) {
await tx.creditTransaction.create({
data: {
walletId: wallet.id,
amount: initialGrant,
type: 'GRANT',
referenceType: 'signup',
referenceId: userId,
idempotencyKey: `grant:signup:${userId}`,
},
});
}
});
}
async getWallet(userId: string): Promise<WalletResponse> {
await this.ensureWalletForUser(userId);
const wallet = await this.prisma.creditWallet.findUniqueOrThrow({
where: { userId },
include: {
transactions: {
orderBy: { createdAt: 'desc' },
take: 20,
},
},
});
return {
balance: wallet.balance,
periodStart: wallet.periodStart.toISOString(),
transactions: wallet.transactions.map((tx) => ({
id: tx.id,
amount: tx.amount,
type: tx.type,
referenceType: tx.referenceType,
referenceId: tx.referenceId,
createdAt: tx.createdAt.toISOString(),
})),
};
}
async listUsage(userId: string, limit = 20): Promise<UsageListResponse> {
const records = await this.prisma.usageRecord.findMany({
where: { userId },
orderBy: { periodStart: 'desc' },
take: limit,
});
return {
records: records.map((record) => ({
id: record.id,
serverId: record.serverId,
ramMb: record.ramMb,
durationSeconds: record.durationSeconds,
creditsCharged: record.creditsCharged,
periodStart: record.periodStart.toISOString(),
periodEnd: record.periodEnd.toISOString(),
createdAt: record.createdAt.toISOString(),
})),
};
}
async assertCanStartServer(userId: string): Promise<void> {
await this.ensureWalletForUser(userId);
const wallet = await this.prisma.creditWallet.findUnique({
where: { userId },
});
if (!wallet || wallet.balance <= 0) {
throw new ForbiddenException(
'Insufficient credits to start a server. Wait for the next grant or upgrade your plan.',
);
}
}
async assertCanCreateServer(
userId: string,
planId?: string | null,
): Promise<void> {
const plan = planId
? await this.prisma.plan.findUnique({ where: { id: planId } })
: await this.prisma.plan.findFirst({ where: { slug: 'free', isActive: true } });
if (!plan?.maxServersPerUser) {
return;
}
const count = await this.prisma.gameServer.count({
where: {
userId,
status: { not: 'DELETED' },
},
});
if (count >= plan.maxServersPerUser) {
throw new ForbiddenException(
`Server quota reached (${plan.maxServersPerUser} servers on ${plan.name})`,
);
}
}
async grantMonthlyCredits(): Promise<number> {
const plans = await this.prisma.plan.findMany({
where: { isActive: true, monthlyCreditGrant: { gt: 0 } },
});
let granted = 0;
for (const plan of plans) {
const servers = await this.prisma.gameServer.findMany({
where: { planId: plan.id },
select: { userId: true },
distinct: ['userId'],
});
for (const { userId } of servers) {
await this.ensureWalletForUser(userId);
const key = `grant:monthly:${plan.slug}:${userId}:${new Date().toISOString().slice(0, 7)}`;
const wallet = await this.prisma.creditWallet.findUniqueOrThrow({
where: { userId },
});
const existing = await this.prisma.creditTransaction.findUnique({
where: { idempotencyKey: key },
});
if (existing) {
continue;
}
await this.prisma.$transaction([
this.prisma.creditWallet.update({
where: { id: wallet.id },
data: { balance: { increment: plan.monthlyCreditGrant } },
}),
this.prisma.creditTransaction.create({
data: {
walletId: wallet.id,
amount: plan.monthlyCreditGrant,
type: 'GRANT',
referenceType: 'plan',
referenceId: plan.id,
idempotencyKey: key,
},
}),
]);
granted += 1;
}
}
return granted;
}
}

View File

@@ -30,7 +30,18 @@ export class SchedulingService {
actorId: string,
correlationId: string,
): Promise<{ server: GameServer; queuePosition: number }> {
const fullServer = server.planId
? server
: await this.prisma.gameServer.findUniqueOrThrow({
where: { id: server.id },
});
const plan = fullServer.planId
? await this.prisma.plan.findUnique({ where: { id: fullServer.planId } })
: null;
const expiresAt = new Date(Date.now() + START_QUEUE_EXPIRY_MS);
const priority = plan?.queuePriority ?? 0;
const updated = await this.prisma.$transaction(async (tx) => {
await tx.serverStartQueueEntry.upsert({
@@ -39,11 +50,13 @@ export class SchedulingService {
serverId: server.id,
nodeId: server.nodeId!,
status: 'QUEUED',
priority,
correlationId,
expiresAt,
},
update: {
status: 'QUEUED',
priority,
correlationId,
expiresAt,
requestedAt: new Date(),

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { IdleService } from './idle.service';
@Module({
imports: [PrismaModule, NodeBridgeModule],
providers: [IdleService],
exports: [IdleService],
})
export class IdleModule {}

View File

@@ -0,0 +1,79 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { IdleStatusResponse } from '@hexahost/contracts';
import { parseMinecraftListOutput } from '@hexahost/metering';
import { NodeBridgeService } from '../../nodes/node-bridge.service';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
@Injectable()
export class IdleService {
constructor(
private readonly prisma: PrismaService,
private readonly nodeBridge: NodeBridgeService,
) {}
async getIdleStatus(userId: string, serverId: string): Promise<IdleStatusResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
let playerCount: number | null = null;
if (server.status === 'RUNNING' && server.nodeId) {
const response = await this.nodeBridge.sendAgentRequest(
server.nodeId,
'server.command',
{
serverId: server.id,
generation: server.version,
command: 'list',
},
10_000,
);
if (response.success) {
const output =
typeof response.result === 'object' &&
response.result !== null &&
'output' in response.result &&
typeof (response.result as { output: unknown }).output === 'string'
? (response.result as { output: string }).output
: typeof response.result === 'string'
? response.result
: '';
playerCount = parseMinecraftListOutput(output);
}
}
const secondsRemaining =
server.idleStopAt && server.idleStopAt.getTime() > Date.now()
? Math.ceil((server.idleStopAt.getTime() - Date.now()) / 1000)
: null;
return {
enabled: server.idleShutdownEnabled,
playerCount,
idleStopAt: server.idleStopAt?.toISOString() ?? null,
secondsRemaining,
lastPlayerSeenAt: server.lastPlayerSeenAt?.toISOString() ?? null,
};
}
async extendIdleCountdown(userId: string, serverId: string): Promise<IdleStatusResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
if (!server.idleStopAt) {
throw new BadRequestException('No idle shutdown countdown is active');
}
const extended = new Date(server.idleStopAt.getTime() + 5 * 60_000);
await this.prisma.gameServer.update({
where: { id: serverId },
data: { idleStopAt: extended },
});
return this.getIdleStatus(userId, serverId);
}
}

View File

@@ -19,12 +19,16 @@ import { SessionGuard } from '../auth/guards/session.guard';
import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe';
import { ServersService } from './servers.service';
import { IdleService } from './idle/idle.service';
@ApiTags('servers')
@Controller('servers')
@UseGuards(SessionGuard)
export class ServersController {
constructor(private readonly serversService: ServersService) {}
constructor(
private readonly serversService: ServersService,
private readonly idleService: IdleService,
) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@@ -104,4 +108,23 @@ export class ServersController {
) {
return this.serversService.cancelQueuedStart(user.id, id);
}
@Get(':id/idle')
@ApiOperation({ summary: 'Get idle shutdown status' })
idleStatus(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.idleService.getIdleStatus(user.id, id);
}
@Post(':id/idle/extend')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Extend idle shutdown countdown by 5 minutes' })
extendIdle(
@CurrentUser() user: { id: string },
@Param('id', ParseUUIDPipe) id: string,
) {
return this.idleService.extendIdleCountdown(user.id, id);
}
}

View File

@@ -8,12 +8,14 @@ import { AuthModule } from '../auth/auth.module';
import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.module';
import { BillingModule } from '../billing/billing.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { ConsoleModule } from './console/console.module';
import { AddonsModule } from './addons/addons.module';
import { BackupsModule } from './backups/backups.module';
import { FilesModule } from './files/files.module';
import { IdleModule } from './idle/idle.module';
import { WorldsModule } from './worlds/worlds.module';
import { PlayersModule } from './players/players.module';
import { PropertiesModule } from './properties/properties.module';
@@ -33,6 +35,8 @@ import { PlansService } from './plans.service';
AppConfigModule,
AuthModule,
SchedulingModule,
BillingModule,
IdleModule,
forwardRef(() => ConsoleModule),
forwardRef(() => AddonsModule),
forwardRef(() => BackupsModule),

View File

@@ -19,6 +19,7 @@ import type { GameServer } from '@hexahost/database';
type GameServerStatus = GameServer['status'];
import { PrismaService } from '../prisma/prisma.service';
import { BillingService } from '../billing/billing.service';
import { SchedulingService } from '../scheduling/scheduling.service';
import { findOwnedServer } from './shared/server-ownership.util';
@@ -36,6 +37,7 @@ export class ServersService {
private readonly prisma: PrismaService,
private readonly serverState: ServerStateService,
private readonly scheduling: SchedulingService,
private readonly billing: BillingService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
@Inject(SERVER_LIFECYCLE_QUEUE)
private readonly lifecycleQueue: Queue,
@@ -47,6 +49,8 @@ export class ServersService {
userId: string,
input: CreateServerRequest,
): Promise<ServerResponse> {
await this.billing.assertCanCreateServer(userId, input.planId);
const server = await this.prisma.gameServer.create({
data: {
userId,
@@ -110,6 +114,8 @@ export class ServersService {
const correlationId = randomUUID();
if (targetStatus === 'STARTING') {
await this.billing.assertCanStartServer(userId);
const canStart = await this.scheduling.canStartServer(server);
if (!canStart) {
const queued = await this.scheduling.enqueueStart(
@@ -288,6 +294,21 @@ export class ServersService {
data: {
status: toStatus,
version: { increment: 1 },
...(toStatus === 'RUNNING'
? {
runningSince: new Date(),
lastPlayerSeenAt: new Date(),
lastMeteredAt: new Date(),
idleStopAt: null,
}
: {}),
...(toStatus === 'STOPPED' || toStatus === 'ERROR'
? {
runningSince: null,
idleStopAt: null,
lastMeteredAt: null,
}
: {}),
},
});
@@ -347,6 +368,8 @@ export class ServersService {
dataPath: server.dataPath,
activeWorldName: server.activeWorldName,
ramMb: server.ramMb,
idleShutdownEnabled: server.idleShutdownEnabled,
idleStopAt: server.idleStopAt?.toISOString() ?? null,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
};