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

@@ -0,0 +1 @@
{"files":{"packages/metering/.turbo/turbo-test.log":{"size":420,"mtime_nanos":1782472703875150900,"mode":420,"is_dir":false}},"order":["packages/metering/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"197df1a8280ccca4","duration":3485,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}

BIN
.turbo/cache/197df1a8280ccca4.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"2192db9e3c33f3c3","duration":6661,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}

BIN
.turbo/cache/2192db9e3c33f3c3.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782472701514992300,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"2986ff6c9a1e2251","duration":1136,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}

BIN
.turbo/cache/2986ff6c9a1e2251.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782472790994800500,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"2e4f0ac4f6283a26","duration":1177,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"321980c702d82a44b534656bb14446f7f514f68894335d76ff0fb3c4d51c68dd"}

BIN
.turbo/cache/2e4f0ac4f6283a26.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":834,"mtime_nanos":1782472708576870000,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"634f3d1fd7cebbd8","duration":1466,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}

BIN
.turbo/cache/634f3d1fd7cebbd8.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/api/.turbo/turbo-test.log":{"size":4348,"mtime_nanos":1782472799870590800,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"696ebaf2e10978bd","duration":10031,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"321980c702d82a44b534656bb14446f7f514f68894335d76ff0fb3c4d51c68dd"}

BIN
.turbo/cache/696ebaf2e10978bd.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":459,"mtime_nanos":1782472704075395000,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"6be92253143ede74","duration":3680,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}

BIN
.turbo/cache/6be92253143ede74.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/.turbo/turbo-build.log":{"size":552,"mtime_nanos":1782472707096561600,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":824,"mtime_nanos":1782472681376271400,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":602,"mtime_nanos":1782472681374899800,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782472681370221900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782472681368160300,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"823347377098d5c5","duration":6700,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}

BIN
.turbo/cache/823347377098d5c5.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/metering/dist/metering.test.d.ts.map":{"size":120,"mtime_nanos":1782472675801247900,"mode":420,"is_dir":false},"packages/metering/dist/metering.test.js.map":{"size":782,"mtime_nanos":1782472675796983900,"mode":420,"is_dir":false},"packages/metering/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/metering/dist/index.d.ts":{"size":561,"mtime_nanos":1782472675785473200,"mode":420,"is_dir":false},"packages/metering/dist/index.js":{"size":1548,"mtime_nanos":1782472675768970100,"mode":420,"is_dir":false},"packages/metering/.turbo/turbo-build.log":{"size":109,"mtime_nanos":1782472675879768300,"mode":420,"is_dir":false},"packages/metering/dist/metering.test.js":{"size":901,"mtime_nanos":1782472675798978000,"mode":420,"is_dir":false},"packages/metering/dist/index.d.ts.map":{"size":479,"mtime_nanos":1782472675783121900,"mode":420,"is_dir":false},"packages/metering/dist/metering.test.d.ts":{"size":54,"mtime_nanos":1782472675803120500,"mode":420,"is_dir":false},"packages/metering/dist/index.js.map":{"size":1220,"mtime_nanos":1782472675767816700,"mode":420,"is_dir":false}},"order":["packages/metering/.turbo/turbo-build.log","packages/metering/dist","packages/metering/dist/index.d.ts","packages/metering/dist/index.d.ts.map","packages/metering/dist/index.js","packages/metering/dist/index.js.map","packages/metering/dist/metering.test.d.ts","packages/metering/dist/metering.test.d.ts.map","packages/metering/dist/metering.test.js","packages/metering/dist/metering.test.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"9da718874699f928","duration":2997,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}

BIN
.turbo/cache/9da718874699f928.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"a8bd232d3ec1411e","duration":2949,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}

BIN
.turbo/cache/a8bd232d3ec1411e.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"abd2828dcb6eea19","duration":1838,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"d6f95e4f5bda48cbff2c068d391f19cb1b01310d5ff19103fcea0de084e63e56"}

BIN
.turbo/cache/abd2828dcb6eea19.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"c63749023bdfc94b","duration":37003,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"44b60676ebd06e68917ed54561fd2c0e75b38e86ee9002b047c654ba1027fb48"}

BIN
.turbo/cache/c63749023bdfc94b.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":602,"mtime_nanos":1782472681374899800,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782472681485813000,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782472681370221900,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782472681368160300,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":824,"mtime_nanos":1782472681376271400,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"e8061f35e521ce42","duration":8589,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"3eccc318fad7b05922f51c036e52850a9cc1f4a4e0fa07ed274eac721dbbf35e"}

BIN
.turbo/cache/e8061f35e521ce42.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"ee13503c61e1308d","duration":4605,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"44b60676ebd06e68917ed54561fd2c0e75b38e86ee9002b047c654ba1027fb48"}

BIN
.turbo/cache/ee13503c61e1308d.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"fe5d58f871b87672","duration":11586,"sha":"49a98ee31dc00ff6e9ea8c520653a613e443a5b6","dirty_hash":"44b60676ebd06e68917ed54561fd2c0e75b38e86ee9002b047c654ba1027fb48"}

BIN
.turbo/cache/fe5d58f871b87672.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -92,7 +92,7 @@ make build
## Aktueller Stand
**Phase 6** (Multi-Node & Queue) — abgeschlossen.
**Phase 7** (Idle Shutdown & Free-Tier) — abgeschlossen.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.

View File

@@ -20,6 +20,7 @@
"@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*",
"@hexahost/metering": "workspace:*",
"@hexahost/scheduler": "workspace:*",
"@hexahost/storage": "workspace:*",
"@nestjs/common": "^11.0.7",

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

View File

@@ -0,0 +1,28 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('phase 7 api contract', () => {
it('documents idle shutdown and billing routes', () => {
const billingSource = readFileSync(
join(process.cwd(), 'src', 'billing/billing.controller.ts'),
'utf8',
);
assert.ok(billingSource.includes("'wallet'"));
assert.ok(billingSource.includes("'usage'"));
const idleSource = readFileSync(
join(process.cwd(), 'src', 'servers/idle/idle.service.ts'),
'utf8',
);
assert.ok(idleSource.includes('getIdleStatus'));
assert.ok(idleSource.includes('extendIdleCountdown'));
const serversSource = readFileSync(
join(process.cwd(), 'src', 'servers/servers.controller.ts'),
'utf8',
);
assert.ok(serversSource.includes(':id/idle'));
});
});

View File

@@ -108,6 +108,9 @@
"allocatedRam": "Zugewiesener RAM",
"activitySubtitle": "Übersicht",
"totalServers": "Server gesamt",
"credits": "Credits",
"creditsSubtitle": "Laufzeit-Guthaben",
"creditsRemaining": "Verbleibende Credits",
"viewAllServers": "Alle Server anzeigen",
"serversSummary": "Server in Ihrem Konto"
},
@@ -154,6 +157,7 @@
"ram": "RAM",
"start": "Starten",
"stop": "Stoppen",
"idleCountdown": "Auto-Stop in {seconds}s (keine Spieler)",
"details": "Details",
"detailSubtitle": "Serverdetails und Lifecycle-Steuerung",
"backToList": "Zurück zur Serverliste",

View File

@@ -108,6 +108,9 @@
"allocatedRam": "Allocated RAM",
"activitySubtitle": "Overview",
"totalServers": "Total servers",
"credits": "Credits",
"creditsSubtitle": "Runtime balance",
"creditsRemaining": "Credits remaining",
"viewAllServers": "View all servers",
"serversSummary": "Servers in your account"
},
@@ -154,6 +157,7 @@
"ram": "RAM",
"start": "Start",
"stop": "Stop",
"idleCountdown": "Auto-stop in {seconds}s (no players)",
"details": "Details",
"detailSubtitle": "Server details and lifecycle controls",
"backToList": "Back to servers",

View File

@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { Link, useRouter } from "@/i18n/navigation";
import { EmptyState } from "@/components/dashboard/empty-state";
import { DashboardSkeleton } from "@/components/dashboard/skeleton";
import { getWallet } from "@/lib/api/billing";
import { listServers } from "@/lib/api/servers";
export function DashboardContent() {
@@ -15,6 +16,10 @@ export function DashboardContent() {
queryKey: ["servers"],
queryFn: listServers,
});
const { data: wallet } = useQuery({
queryKey: ["wallet"],
queryFn: getWallet,
});
const servers = data?.servers ?? [];
const activeCount = servers.filter((server) =>
@@ -63,14 +68,14 @@ export function DashboardContent() {
<Card>
<CardHeader>
<CardTitle>{t("activity")}</CardTitle>
<CardDescription>{t("activitySubtitle")}</CardDescription>
<CardTitle>{t("credits")}</CardTitle>
<CardDescription>{t("creditsSubtitle")}</CardDescription>
</CardHeader>
<CardContent>
<p className="font-mono text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
{servers.length}
{wallet?.balance ?? "—"}
</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("totalServers")}</p>
<p className="text-sm text-zinc-600 dark:text-zinc-400">{t("creditsRemaining")}</p>
</CardContent>
</Card>
</div>

View File

@@ -1,11 +1,12 @@
"use client";
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { Link } from "@/i18n/navigation";
import { ApiError } from "@/lib/api/auth";
import { getServerIdleStatus } from "@/lib/api/billing";
import {
type Server,
startServer,
@@ -23,6 +24,13 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const { data: idleStatus } = useQuery({
queryKey: ["server-idle", server.id],
queryFn: () => getServerIdleStatus(server.id),
enabled: server.status === "RUNNING",
refetchInterval: server.status === "RUNNING" ? 15_000 : false,
});
const startMutation = useMutation({
mutationFn: () => startServer(server.id),
onSuccess: () => {
@@ -47,9 +55,11 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
},
});
const canStart = server.status === "STOPPED";
const canStart = server.status === "STOPPED" || server.status === "UNKNOWN";
const canStop = server.status === "RUNNING";
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING"].includes(server.status);
const isBusy = ["PROVISIONING", "INSTALLING", "STARTING", "STOPPING", "QUEUED"].includes(
server.status,
);
const address =
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending");
@@ -76,6 +86,12 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
</div>
</dl>
{server.status === "RUNNING" && idleStatus?.secondsRemaining ? (
<p className="text-sm text-amber-700 dark:text-amber-300" role="status">
{t("idleCountdown", { seconds: idleStatus.secondsRemaining })}
</p>
) : null}
{actionError ? (
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
{actionError}

View File

@@ -8,9 +8,11 @@ const statusStyles: Record<GameServerStatus, string> = {
PROVISIONING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
INSTALLING: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
STOPPED: "bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300",
QUEUED: "bg-violet-100 text-violet-800 dark:bg-violet-950 dark:text-violet-300",
STARTING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
RUNNING: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300",
STOPPING: "bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300",
UNKNOWN: "bg-orange-100 text-orange-800 dark:bg-orange-950 dark:text-orange-300",
ERROR: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
DELETING: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
DELETED: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-500",

View File

@@ -0,0 +1,30 @@
import { apiFetch } from "./auth";
export interface WalletResponse {
balance: number;
periodStart: string;
transactions: Array<{
id: string;
amount: number;
type: string;
referenceType: string | null;
referenceId: string | null;
createdAt: string;
}>;
}
export interface IdleStatusResponse {
enabled: boolean;
playerCount: number | null;
idleStopAt: string | null;
secondsRemaining: number | null;
lastPlayerSeenAt: string | null;
}
export async function getWallet(): Promise<WalletResponse> {
return apiFetch<WalletResponse>("/account/wallet");
}
export async function getServerIdleStatus(serverId: string): Promise<IdleStatusResponse> {
return apiFetch<IdleStatusResponse>(`/servers/${serverId}/idle`);
}

View File

@@ -5,9 +5,11 @@ export type GameServerStatus =
| "PROVISIONING"
| "INSTALLING"
| "STOPPED"
| "QUEUED"
| "STARTING"
| "RUNNING"
| "STOPPING"
| "UNKNOWN"
| "ERROR"
| "DELETING"
| "DELETED";
@@ -38,6 +40,8 @@ export interface Server {
containerId: string | null;
dataPath: string | null;
ramMb: number;
idleShutdownEnabled: boolean;
idleStopAt: string | null;
createdAt: string;
updatedAt: string;
}
@@ -49,6 +53,7 @@ export interface ServerListResponse {
export interface ServerActionResponse {
server: Server;
jobId?: string;
queuePosition?: number;
}
export interface CreateServerPayload {

View File

@@ -15,6 +15,7 @@
"@hexahost/catalog": "workspace:*",
"@hexahost/config": "workspace:*",
"@hexahost/database": "workspace:*",
"@hexahost/metering": "workspace:*",
"@hexahost/scheduler": "workspace:*",
"@hexahost/storage": "workspace:*",
"bullmq": "^5.34.10",

View File

@@ -0,0 +1,273 @@
import { randomUUID } from 'node:crypto';
import { prisma } from '@hexahost/database';
import {
calculateUsageCredits,
parseMinecraftListOutput,
resolveIdlePolicy,
} from '@hexahost/metering';
import { Queue } from 'bullmq';
import { sendAgentRequest } from '../agent-bridge';
import { logger } from '../logger';
import { getRedisPublisher } from '../redis';
import { QUEUE_SERVER_LIFECYCLE } from '../queues';
let lifecycleQueue: Queue | null = null;
function getLifecycleQueue(): Queue {
if (!lifecycleQueue) {
lifecycleQueue = new Queue(QUEUE_SERVER_LIFECYCLE, {
connection: getRedisPublisher(),
});
}
return lifecycleQueue;
}
async function fetchPlayerCount(server: {
id: string;
nodeId: string | null;
version: number;
}): Promise<number | null> {
if (!server.nodeId) {
return null;
}
const response = await sendAgentRequest(
server.nodeId,
'server.command',
{
serverId: server.id,
generation: server.version,
command: 'list',
},
10_000,
);
if (!response.success) {
return null;
}
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
: '';
return parseMinecraftListOutput(output);
}
export async function processIdleShutdownTick(): Promise<void> {
const servers = await prisma.gameServer.findMany({
where: {
status: 'RUNNING',
idleShutdownEnabled: true,
},
include: { plan: true },
});
for (const server of servers) {
try {
await processServerIdle(server);
} catch (error) {
logger.error({ err: error, serverId: server.id }, 'Idle shutdown check failed');
}
}
}
async function processServerIdle(
server: Awaited<ReturnType<typeof prisma.gameServer.findMany>>[number] & {
plan: {
idleTimeoutMinutes: number;
idleGraceAfterStartMinutes: number;
idleCountdownSeconds: number;
} | null;
},
): Promise<void> {
const activeBackup = await prisma.serverBackup.findFirst({
where: {
serverId: server.id,
status: { in: ['PENDING', 'RUNNING'] },
},
});
if (activeBackup) {
return;
}
const activeRestore = await prisma.backupRestore.findFirst({
where: {
serverId: server.id,
status: { in: ['PENDING', 'RUNNING'] },
},
});
if (activeRestore) {
return;
}
const policy = resolveIdlePolicy(server.plan);
const now = Date.now();
const playerCount = await fetchPlayerCount(server);
if (playerCount !== null && playerCount > 0) {
await prisma.gameServer.update({
where: { id: server.id },
data: {
lastPlayerSeenAt: new Date(),
idleStopAt: null,
},
});
return;
}
const runningSince = server.runningSince?.getTime() ?? now;
if (now - runningSince < policy.idleGraceAfterStartMinutes * 60_000) {
return;
}
const lastActive = server.lastPlayerSeenAt?.getTime() ?? runningSince;
const idleMs = now - lastActive;
if (idleMs < policy.idleTimeoutMinutes * 60_000) {
if (server.idleStopAt) {
await prisma.gameServer.update({
where: { id: server.id },
data: { idleStopAt: null },
});
}
return;
}
if (!server.idleStopAt) {
await prisma.gameServer.update({
where: { id: server.id },
data: {
idleStopAt: new Date(now + policy.idleCountdownSeconds * 1000),
},
});
logger.info({ serverId: server.id }, 'Idle stop countdown started');
return;
}
if (server.idleStopAt.getTime() <= now) {
const correlationId = randomUUID();
await getLifecycleQueue().add(
'stop-server',
{ serverId: server.id, correlationId, reason: 'idle_shutdown' },
{ jobId: correlationId },
);
await prisma.gameServer.update({
where: { id: server.id },
data: { idleStopAt: null },
});
logger.info({ serverId: server.id }, 'Idle shutdown stop enqueued');
}
}
export async function processUsageMeteringTick(): Promise<void> {
const servers = await prisma.gameServer.findMany({
where: { status: 'RUNNING' },
include: { plan: true },
});
const now = new Date();
for (const server of servers) {
try {
await meterServerUsage(server, now);
} catch (error) {
logger.error({ err: error, serverId: server.id }, 'Usage metering failed');
}
}
}
async function meterServerUsage(
server: {
id: string;
userId: string;
ramMb: number;
lastMeteredAt: Date | null;
runningSince: Date | null;
},
now: Date,
): Promise<void> {
const periodStart = server.lastMeteredAt ?? server.runningSince ?? now;
const durationSeconds = Math.floor((now.getTime() - periodStart.getTime()) / 1000);
if (durationSeconds < 30) {
return;
}
const credits = calculateUsageCredits(server.ramMb, durationSeconds);
const idempotencyKey = `usage:${server.id}:${periodStart.toISOString()}`;
const wallet = await prisma.creditWallet.findUnique({
where: { userId: server.userId },
});
if (!wallet) {
await prisma.gameServer.update({
where: { id: server.id },
data: { lastMeteredAt: now },
});
return;
}
await prisma.$transaction(async (tx) => {
const existing = await tx.usageRecord.findUnique({
where: { idempotencyKey },
});
if (existing) {
return;
}
await tx.usageRecord.create({
data: {
serverId: server.id,
userId: server.userId,
ramMb: server.ramMb,
durationSeconds,
creditsCharged: credits,
periodStart,
periodEnd: now,
idempotencyKey,
},
});
const nextBalance = Math.max(0, wallet.balance - credits);
await tx.creditWallet.update({
where: { id: wallet.id },
data: { balance: nextBalance },
});
await tx.creditTransaction.create({
data: {
walletId: wallet.id,
amount: -credits,
type: 'USAGE_DEBIT',
referenceType: 'server',
referenceId: server.id,
idempotencyKey: `debit:${idempotencyKey}`,
metadata: {
durationSeconds,
ramMb: server.ramMb,
},
},
});
await tx.gameServer.update({
where: { id: server.id },
data: { lastMeteredAt: now },
});
});
}

View File

@@ -3,6 +3,7 @@ import { Job, Worker, type ConnectionOptions } from 'bullmq';
import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database';
import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing';
import { processNodeHealthTick } from './handlers/node-health';
import { processNotificationJob } from './handlers/notifications';
import { processAddonJob } from './handlers/server-addons';
@@ -99,6 +100,12 @@ async function bootstrap(): Promise<void> {
void processNodeHealthTick().catch((error: Error) => {
logger.error({ err: error }, 'Node health tick failed');
});
void processIdleShutdownTick().catch((error: Error) => {
logger.error({ err: error }, 'Idle shutdown tick failed');
});
void processUsageMeteringTick().catch((error: Error) => {
logger.error({ err: error }, 'Usage metering tick failed');
});
}, 60_000);
const shutdown = async (signal: string): Promise<void> => {

File diff suppressed because one or more lines are too long

View File

@@ -2,53 +2,54 @@
Last updated: 2026-06-26
## Current phase: Phase 6Multi-Node and queue
## Current phase: Phase 7Idle shutdown and free tier
### Phase 6 completed
### Phase 7 completed
| Area | Status | Notes |
|------|--------|-------|
| `@hexahost/scheduler` | Done | Node scoring, RAM/slot capacity, least-loaded placement |
| Prisma start queue + node capacity | Done | `ServerStartQueueEntry`, `maxRamMb`, `platformRamMb`, `QUEUED`/`UNKNOWN` |
| `@hexahost/contracts` phase 6 | Done | `nodes.ts`, queue schemas, extended server status |
| Worker scheduler integration | Done | Provisioning uses `selectNodeForAllocation` |
| Start queue | Done | API enqueue + worker tick dispatch |
| Node health tick | Done | Heartbeat timeout → `UNREACHABLE`, servers → `UNKNOWN` |
| Drain / maintenance | Done | Admin `POST /admin/nodes/:id/drain` and `/maintenance` |
| Allocation release | Done | Released on provision failure |
| Dev seed | Done | Second node `local-dev-2` for multi-node tests |
| `@hexahost/metering` | Done | RAM-GB-minute credits, Minecraft `list` parser |
| Prisma billing models | Done | `CreditWallet`, `CreditTransaction`, `UsageRecord` |
| Plan idle/quota fields | Done | Timeouts, countdown, `maxServersPerUser`, `queuePriority` |
| Idle controller (worker) | Done | Player poll, countdown, auto-stop |
| Usage metering (worker) | Done | 60s tick debits wallet, writes `UsageRecord` |
| API billing | Done | `GET /account/wallet`, `GET /account/usage` |
| API idle | Done | `GET /servers/:id/idle`, `POST .../idle/extend` |
| Quotas & credits | Done | Server create/start guards |
| Web | Done | Credit balance on dashboard, idle countdown on server card |
### Abnahmekriterium Phase 6
### Abnahmekriterium Phase 7
- [x] Scheduler distributes servers across eligible nodes (least loaded RAM)
- [x] Resource reservation via `GameServerAllocation` + node RAM budget
- [x] Start queue when node lacks capacity
- [x] Drain excludes node from new allocations
- [x] Maintenance mode blocks scheduling
- [x] Node heartbeat loss marks servers `UNKNOWN`
- [ ] Manual E2E with two agents on different nodes
- [x] Empty running server triggers idle countdown and auto-stop
- [x] Player connection cancels countdown (via `list` poll)
- [x] Usage debited to credit wallet with ledger + usage records
- [x] Free plan grants credits on signup
- [x] Server quota per plan enforced
- [ ] Manual E2E: start server, wait idle, verify stop + usage
### Local test (two nodes)
### Local test
1. `pnpm db:seed` — note both enrollment tokens
2. Start agent 1 with `NODE_ID=...001` and agent 2 with `NODE_ID=...002`
3. Create several servers and start them — verify different `nodeId` in API responses
4. Saturate one node RAM (`maxRamMb` low) → next server lands on other node
5. `POST /admin/nodes/:id/drain` — new provisions skip drained node
1. `pnpm db:migrate && pnpm db:seed`
2. Register/login → dashboard shows **5000 credits** (free plan)
3. Create & start server → credits decrease while running
4. Leave server empty → after ~10 min idle + 60s countdown → auto-stop
5. `GET /account/usage` shows RAM-GB-minute records
### Known limitations
### Free plan defaults (seed)
- Offline migration not implemented (manual re-provision only)
- No admin UI for nodes yet (API only)
- Queue priority uses plan metadata placeholder (`priority=0`)
- CPU overcommit not configurable yet
- `idleTimeoutMinutes`: 10
- `idleCountdownSeconds`: 60
- `monthlyCreditGrant`: 5000
- `maxServersPerUser`: 3
---
## Phase 6 — Multi-Node and queue ✅
---
## Phase 5 — Software & add-ons ✅
Modrinth catalog, addon install/remove, SHA512 verify.
---
## Phase 4 — Worlds & backups ✅
@@ -69,6 +70,6 @@ Modrinth catalog, addon install/remove, SHA512 verify.
## Phase 0 — Monorepo foundation ✅
### Next phase: Phase 7Idle shutdown and free tier
### Next phase: Phase 8DNS and join-to-start
- Player detection, auto-stop, quotas, credits, usage metering
- Port pools, DNS-SRV, edge gateway, start on connect

View File

@@ -0,0 +1,43 @@
import { z } from 'zod';
export const idleStatusResponseSchema = z.object({
enabled: z.boolean(),
playerCount: z.number().int().nullable(),
idleStopAt: z.string().datetime().nullable(),
secondsRemaining: z.number().int().nullable(),
lastPlayerSeenAt: z.string().datetime().nullable(),
});
export const creditTransactionSchema = z.object({
id: z.string().uuid(),
amount: z.number().int(),
type: z.enum(['GRANT', 'USAGE_DEBIT', 'ADJUSTMENT', 'EXPIRE']),
referenceType: z.string().nullable(),
referenceId: z.string().nullable(),
createdAt: z.string().datetime(),
});
export const walletResponseSchema = z.object({
balance: z.number().int(),
periodStart: z.string().datetime(),
transactions: z.array(creditTransactionSchema),
});
export const usageRecordSchema = z.object({
id: z.string().uuid(),
serverId: z.string().uuid(),
ramMb: z.number().int(),
durationSeconds: z.number().int(),
creditsCharged: z.number().int(),
periodStart: z.string().datetime(),
periodEnd: z.string().datetime(),
createdAt: z.string().datetime(),
});
export const usageListResponseSchema = z.object({
records: z.array(usageRecordSchema),
});
export type IdleStatusResponse = z.infer<typeof idleStatusResponseSchema>;
export type WalletResponse = z.infer<typeof walletResponseSchema>;
export type UsageListResponse = z.infer<typeof usageListResponseSchema>;

View File

@@ -194,3 +194,14 @@ export {
type StartQueueEntry,
type StartQueueResponse,
} from './nodes';
export {
idleStatusResponseSchema,
creditTransactionSchema,
walletResponseSchema,
usageRecordSchema,
usageListResponseSchema,
type IdleStatusResponse,
type WalletResponse,
type UsageListResponse,
} from './billing';

View File

@@ -59,6 +59,8 @@ export const serverResponseSchema = z.object({
dataPath: z.string().nullable(),
activeWorldName: z.string(),
ramMb: z.number().int(),
idleShutdownEnabled: z.boolean(),
idleStopAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,79 @@
-- Phase 7: Idle shutdown, credits, usage metering, plan quotas
CREATE TYPE "CreditTransactionType" AS ENUM ('GRANT', 'USAGE_DEBIT', 'ADJUSTMENT', 'EXPIRE');
ALTER TABLE "plans"
ADD COLUMN IF NOT EXISTS "maxServersPerUser" INTEGER,
ADD COLUMN IF NOT EXISTS "idleTimeoutMinutes" INTEGER NOT NULL DEFAULT 15,
ADD COLUMN IF NOT EXISTS "idleGraceAfterStartMinutes" INTEGER NOT NULL DEFAULT 3,
ADD COLUMN IF NOT EXISTS "idleCountdownSeconds" INTEGER NOT NULL DEFAULT 120,
ADD COLUMN IF NOT EXISTS "queuePriority" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS "monthlyCreditGrant" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "game_servers"
ADD COLUMN IF NOT EXISTS "idleShutdownEnabled" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS "lastPlayerSeenAt" TIMESTAMPTZ(3),
ADD COLUMN IF NOT EXISTS "idleStopAt" TIMESTAMPTZ(3),
ADD COLUMN IF NOT EXISTS "runningSince" TIMESTAMPTZ(3),
ADD COLUMN IF NOT EXISTS "lastMeteredAt" TIMESTAMPTZ(3);
CREATE TABLE "credit_wallets" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"balance" INTEGER NOT NULL DEFAULT 0,
"periodStart" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "credit_wallets_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "credit_wallets_userId_key" ON "credit_wallets"("userId");
ALTER TABLE "credit_wallets"
ADD CONSTRAINT "credit_wallets_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "credit_transactions" (
"id" TEXT NOT NULL,
"walletId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"type" "CreditTransactionType" NOT NULL,
"referenceType" TEXT,
"referenceId" TEXT,
"idempotencyKey" TEXT NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "credit_transactions_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "credit_transactions_idempotencyKey_key" ON "credit_transactions"("idempotencyKey");
CREATE INDEX "credit_transactions_walletId_createdAt_idx" ON "credit_transactions"("walletId", "createdAt");
ALTER TABLE "credit_transactions"
ADD CONSTRAINT "credit_transactions_walletId_fkey"
FOREIGN KEY ("walletId") REFERENCES "credit_wallets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
CREATE TABLE "usage_records" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"ramMb" INTEGER NOT NULL,
"durationSeconds" INTEGER NOT NULL,
"creditsCharged" INTEGER NOT NULL,
"periodStart" TIMESTAMPTZ(3) NOT NULL,
"periodEnd" TIMESTAMPTZ(3) NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "usage_records_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "usage_records_idempotencyKey_key" ON "usage_records"("idempotencyKey");
CREATE INDEX "usage_records_serverId_periodStart_idx" ON "usage_records"("serverId", "periodStart");
CREATE INDEX "usage_records_userId_periodStart_idx" ON "usage_records"("userId", "periodStart");
ALTER TABLE "usage_records"
ADD CONSTRAINT "usage_records_serverId_fkey"
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -121,6 +121,13 @@ enum AddonInstallStatus {
REMOVED
}
enum CreditTransactionType {
GRANT
USAGE_DEBIT
ADJUSTMENT
EXPIRE
}
model User {
id String @id @default(uuid())
email String @unique
@@ -141,6 +148,7 @@ model User {
emailVerificationTokens EmailVerificationToken[]
passwordResetTokens PasswordResetToken[]
totpCredential TotpCredential?
creditWallet CreditWallet?
@@map("users")
}
@@ -278,6 +286,11 @@ model GameServer {
dataPath String?
ramMb Int @default(1024)
activeWorldName String @default("world")
idleShutdownEnabled Boolean @default(true)
lastPlayerSeenAt DateTime? @db.Timestamptz(3)
idleStopAt DateTime? @db.Timestamptz(3)
runningSince DateTime? @db.Timestamptz(3)
lastMeteredAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -292,6 +305,7 @@ model GameServer {
worldUploads WorldUpload[]
installedAddons InstalledAddon[]
startQueueEntry ServerStartQueueEntry?
usageRecords UsageRecord[]
@@index([userId])
@@index([nodeId])
@@ -406,6 +420,12 @@ model Plan {
maxRamMb Int
maxCpuCores Float
maxStorageMb Int
maxServersPerUser Int?
idleTimeoutMinutes Int @default(15)
idleGraceAfterStartMinutes Int @default(3)
idleCountdownSeconds Int @default(120)
queuePriority Int @default(0)
monthlyCreditGrant Int @default(0)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -475,6 +495,56 @@ model JobRecord {
@@map("job_records")
}
model CreditWallet {
id String @id @default(uuid())
userId String @unique
balance Int @default(0)
periodStart DateTime @default(now()) @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transactions CreditTransaction[]
@@map("credit_wallets")
}
model CreditTransaction {
id String @id @default(uuid())
walletId String
amount Int
type CreditTransactionType
referenceType String?
referenceId String?
idempotencyKey String @unique
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
wallet CreditWallet @relation(fields: [walletId], references: [id], onDelete: Cascade)
@@index([walletId, createdAt])
@@map("credit_transactions")
}
model UsageRecord {
id String @id @default(uuid())
serverId String
userId String
ramMb Int
durationSeconds Int
creditsCharged Int
periodStart DateTime @db.Timestamptz(3)
periodEnd DateTime @db.Timestamptz(3)
idempotencyKey String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@index([serverId, periodStart])
@@index([userId, periodStart])
@@map("usage_records")
}
model ServerBackup {
id String @id @default(uuid())
serverId String

View File

@@ -29,9 +29,21 @@ async function main(): Promise<void> {
maxRamMb: 2048,
maxCpuCores: 1,
maxStorageMb: 5120,
maxServersPerUser: 3,
idleTimeoutMinutes: 10,
idleGraceAfterStartMinutes: 2,
idleCountdownSeconds: 60,
queuePriority: 0,
monthlyCreditGrant: 5000,
},
update: {
isActive: true,
maxServersPerUser: 3,
idleTimeoutMinutes: 10,
idleGraceAfterStartMinutes: 2,
idleCountdownSeconds: 60,
queuePriority: 0,
monthlyCreditGrant: 5000,
},
});

View File

@@ -20,6 +20,9 @@ export type {
GameNode,
GameNodeHeartbeat,
ServerStartQueueEntry,
CreditWallet,
CreditTransaction,
UsageRecord,
Plan,
FeatureFlag,
SystemSetting,

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/metering",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -0,0 +1,48 @@
/** Credits charged for RAM usage: 1 credit per GB-minute (rounded up). */
export function calculateUsageCredits(ramMb: number, durationSeconds: number): number {
if (ramMb <= 0 || durationSeconds <= 0) {
return 0;
}
const gbMinutes = (ramMb / 1024) * (durationSeconds / 60);
return Math.max(1, Math.ceil(gbMinutes));
}
export function parseMinecraftListOutput(output: string): number {
const headerMatch = output.match(
/There are (\d+) of a max(?:imum)? of \d+ players? online/i,
);
if (headerMatch?.[1]) {
return Number.parseInt(headerMatch[1], 10);
}
const legacyMatch = output.match(/^(\d+) players? online/i);
if (legacyMatch?.[1]) {
return Number.parseInt(legacyMatch[1], 10);
}
return 0;
}
export interface IdlePolicy {
idleTimeoutMinutes: number;
idleGraceAfterStartMinutes: number;
idleCountdownSeconds: number;
}
export const DEFAULT_IDLE_POLICY: IdlePolicy = {
idleTimeoutMinutes: 15,
idleGraceAfterStartMinutes: 3,
idleCountdownSeconds: 120,
};
export function resolveIdlePolicy(plan?: Partial<IdlePolicy> | null): IdlePolicy {
return {
idleTimeoutMinutes: plan?.idleTimeoutMinutes ?? DEFAULT_IDLE_POLICY.idleTimeoutMinutes,
idleGraceAfterStartMinutes:
plan?.idleGraceAfterStartMinutes ?? DEFAULT_IDLE_POLICY.idleGraceAfterStartMinutes,
idleCountdownSeconds:
plan?.idleCountdownSeconds ?? DEFAULT_IDLE_POLICY.idleCountdownSeconds,
};
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import {
calculateUsageCredits,
parseMinecraftListOutput,
} from './index';
describe('metering', () => {
it('charges credits by RAM-GB-minutes', () => {
expect(calculateUsageCredits(1024, 60)).toBe(1);
expect(calculateUsageCredits(2048, 60)).toBe(2);
expect(calculateUsageCredits(512, 30)).toBe(1);
});
it('parses minecraft list command output', () => {
expect(
parseMinecraftListOutput('There are 2 of a max of 20 players online: Steve, Alex'),
).toBe(2);
expect(
parseMinecraftListOutput('There are 0 of a maximum of 10 players online:'),
).toBe(0);
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

21
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
'@hexahost/database':
specifier: workspace:*
version: link:../../packages/database
'@hexahost/metering':
specifier: workspace:*
version: link:../../packages/metering
'@hexahost/scheduler':
specifier: workspace:*
version: link:../../packages/scheduler
@@ -187,6 +190,9 @@ importers:
'@hexahost/database':
specifier: workspace:*
version: link:../../packages/database
'@hexahost/metering':
specifier: workspace:*
version: link:../../packages/metering
'@hexahost/scheduler':
specifier: workspace:*
version: link:../../packages/scheduler
@@ -321,6 +327,21 @@ importers:
packages/eslint-config: {}
packages/metering:
devDependencies:
'@hexahost/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/node':
specifier: ^22.10.2
version: 22.20.0
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^3.0.8
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
packages/scheduler:
devDependencies:
'@hexahost/typescript-config':