Phase9
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 17s

This commit is contained in:
smueller
2026-06-26 13:46:25 +02:00
parent b335f6a497
commit ed8334328e
49 changed files with 2219 additions and 16 deletions

View File

@@ -93,6 +93,6 @@ API_PORT=3001
WEB_PORT=3000
# --- WHMCS Integration (optional) ---
WHMCS_INTEGRATION_ID=
WHMCS_API_SECRET=
WHMCS_INTEGRATION_ID=local-dev-whmcs
WHMCS_API_SECRET=local-dev-whmcs-api-secret-32chars-minimum
WHMCS_WEBHOOK_SECRET=

View File

@@ -68,7 +68,7 @@ packages/
ui/ Gemeinsame UI-Komponenten
deploy/ Docker Compose, systemd, Ansible
docs/ Architektur, ADR, Betrieb
integrations/ WHMCS-Module (später)
integrations/ WHMCS-Modul (`hexagamecloud`)
```
## Befehle
@@ -92,7 +92,7 @@ make build
## Aktueller Stand
**Phase 8** (DNS & Join-to-Start) — abgeschlossen.
**Phase 9** (WHMCS & Produktion) — abgeschlossen.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.

View File

@@ -21,6 +21,7 @@
"@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*",
"@hexahost/dns": "workspace:*",
"@hexahost/integration-auth": "workspace:*",
"@hexahost/metering": "workspace:*",
"@hexahost/scheduler": "workspace:*",
"@hexahost/storage": "workspace:*",

View File

@@ -8,6 +8,7 @@ import { BillingModule } from './billing/billing.module';
import { CatalogModule } from './catalog/catalog.module';
import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module';
import { IntegrationsModule } from './integrations/integrations.module';
import { EdgeModule } from './edge/edge.module';
import { ServersModule } from './servers/servers.module';
import { AppConfigModule } from './config/app-config.module';
@@ -46,6 +47,7 @@ import { LoggerModule } from 'nestjs-pino';
CatalogModule,
BillingModule,
EdgeModule,
IntegrationsModule,
ServersModule,
NodesModule,
AdminModule,

View File

@@ -4,6 +4,10 @@ import type { UsageListResponse, WalletResponse } from '@hexahost/contracts';
import { PrismaService } from '../prisma/prisma.service';
function isWhmcsBilling(): boolean {
return (process.env['BILLING_PROVIDER'] ?? 'internal') === 'whmcs';
}
@Injectable()
export class BillingService {
constructor(private readonly prisma: PrismaService) {}
@@ -95,6 +99,10 @@ export class BillingService {
}
async assertCanStartServer(userId: string): Promise<void> {
if (isWhmcsBilling()) {
return;
}
await this.ensureWalletForUser(userId);
const wallet = await this.prisma.creditWallet.findUnique({

View File

@@ -138,6 +138,14 @@ export class EdgeService {
};
}
if (server.billingSuspendedAt) {
return {
...base,
action: 'reject',
message: server.billingSuspendReason ?? 'Server is billing suspended',
};
}
if (server.status === 'RUNNING' && backend) {
return { ...base, action: 'proxy', backend };
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { WhmcsIntegrationModule } from './whmcs/whmcs-integration.module';
@Module({
imports: [WhmcsIntegrationModule],
exports: [WhmcsIntegrationModule],
})
export class IntegrationsModule {}

View File

@@ -0,0 +1 @@
export const INTEGRATIONS_REDIS = Symbol('INTEGRATIONS_REDIS');

View File

@@ -0,0 +1,135 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import {
whmcsChangePackageRequestSchema,
whmcsProvisionServiceRequestSchema,
whmcsSuspendRequestSchema,
whmcsUpsertClientRequestSchema,
} from '@hexahost/contracts';
import { INTEGRATION_HEADERS } from '@hexahost/integration-auth';
import {
WhmcsIntegrationGuard,
type WhmcsAuthenticatedRequest,
} from './whmcs-integration.guard';
import { WhmcsIntegrationService } from './whmcs-integration.service';
@Controller('integrations/whmcs')
@UseGuards(WhmcsIntegrationGuard)
@SkipThrottle()
export class WhmcsIntegrationController {
constructor(private readonly whmcs: WhmcsIntegrationService) {}
@Get('health')
health(@Req() request: WhmcsAuthenticatedRequest) {
return this.whmcs.getHealth(request.whmcsInstallation!);
}
@Get('catalog/plans')
listPlans() {
return this.whmcs.listPlans();
}
@Put('clients/:externalClientId')
upsertClient(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalClientId') externalClientId: string,
@Body() body: unknown,
) {
const parsed = whmcsUpsertClientRequestSchema.parse(body);
return this.whmcs.upsertClient(
request.whmcsInstallation!,
externalClientId,
parsed,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services')
provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) {
const parsed = whmcsProvisionServiceRequestSchema.parse(body);
return this.whmcs.provisionService(
request.whmcsInstallation!,
parsed,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Get('services/:externalServiceId')
getService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
) {
return this.whmcs.getService(request.whmcsInstallation!, externalServiceId);
}
@Post('services/:externalServiceId/suspend')
suspendService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
@Body() body: unknown,
) {
const parsed = whmcsSuspendRequestSchema.parse(body ?? {});
return this.whmcs.suspendService(
request.whmcsInstallation!,
externalServiceId,
parsed.reason,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services/:externalServiceId/unsuspend')
unsuspendService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
) {
return this.whmcs.unsuspendService(
request.whmcsInstallation!,
externalServiceId,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Post('services/:externalServiceId/terminate')
terminateService(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
) {
return this.whmcs.terminateService(
request.whmcsInstallation!,
externalServiceId,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
@Patch('services/:externalServiceId/change-package')
changePackage(
@Req() request: WhmcsAuthenticatedRequest,
@Param('externalServiceId') externalServiceId: string,
@Body() body: unknown,
) {
const parsed = whmcsChangePackageRequestSchema.parse(body);
return this.whmcs.changePackage(
request.whmcsInstallation!,
externalServiceId,
parsed,
headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!,
);
}
}
function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined {
const value = request.headers[header];
return typeof value === 'string' ? value : undefined;
}

View File

@@ -0,0 +1,97 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import type Redis from 'ioredis';
import {
INTEGRATION_HEADERS,
canonicalQuery,
verifyRequestSignature,
} from '@hexahost/integration-auth';
import { PrismaService } from '../../prisma/prisma.service';
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
export type WhmcsInstallationContext = {
id: string;
integrationId: string;
apiSecret: string;
};
export type WhmcsAuthenticatedRequest = FastifyRequest & {
whmcsInstallation?: WhmcsInstallationContext;
rawBody?: string;
};
@Injectable()
export class WhmcsIntegrationGuard implements CanActivate {
constructor(
private readonly prisma: PrismaService,
@Inject(INTEGRATIONS_REDIS) private readonly redis: Redis,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<WhmcsAuthenticatedRequest>();
const integrationId = headerValue(request, INTEGRATION_HEADERS.integrationId);
const timestamp = headerValue(request, INTEGRATION_HEADERS.timestamp);
const nonce = headerValue(request, INTEGRATION_HEADERS.nonce);
const idempotencyKey = headerValue(request, INTEGRATION_HEADERS.idempotencyKey);
const signature = headerValue(request, INTEGRATION_HEADERS.signature);
if (!integrationId || !timestamp || !nonce || !idempotencyKey || !signature) {
throw new UnauthorizedException('Missing integration authentication headers');
}
const installation = await this.prisma.whmcsInstallation.findFirst({
where: { integrationId, isActive: true },
select: { id: true, integrationId: true, apiSecret: true },
});
if (!installation) {
throw new UnauthorizedException('Unknown integration installation');
}
const nonceKey = `integration:nonce:${installation.id}:${nonce}`;
const nonceSet = await this.redis.set(nonceKey, '1', 'EX', 600, 'NX');
if (nonceSet !== 'OK') {
throw new UnauthorizedException('Replayed integration nonce');
}
const body = typeof request.rawBody === 'string' ? request.rawBody : JSON.stringify(request.body ?? {});
const query = canonicalQuery(
request.query as Record<string, string | string[] | undefined>,
);
const valid = verifyRequestSignature(
installation.apiSecret,
{
method: request.method,
path: request.url.split('?')[0] ?? request.url,
query,
body,
timestamp,
nonce,
idempotencyKey,
},
signature,
);
if (!valid) {
throw new UnauthorizedException('Invalid integration signature');
}
request.whmcsInstallation = installation;
return true;
}
}
function headerValue(request: FastifyRequest, header: string): string | undefined {
const value = request.headers[header];
return typeof value === 'string' ? value : undefined;
}

View File

@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import Redis from 'ioredis';
import { getConfig } from '@hexahost/config';
import { BillingModule } from '../../billing/billing.module';
import { ServersModule } from '../../servers/servers.module';
import { WhmcsIntegrationController } from './whmcs-integration.controller';
import { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
import { WhmcsIntegrationGuard } from './whmcs-integration.guard';
import { WhmcsIntegrationService } from './whmcs-integration.service';
export { INTEGRATIONS_REDIS } from './whmcs-integration.constants';
@Module({
imports: [ServersModule, BillingModule],
controllers: [WhmcsIntegrationController],
providers: [
WhmcsIntegrationService,
WhmcsIntegrationGuard,
{
provide: INTEGRATIONS_REDIS,
useFactory: () => {
const config = getConfig();
return new Redis(config.REDIS_URL, {
maxRetriesPerRequest: null,
});
},
},
{
provide: Redis,
useExisting: INTEGRATIONS_REDIS,
},
],
})
export class WhmcsIntegrationModule {}

View File

@@ -0,0 +1,509 @@
import { randomUUID } from 'node:crypto';
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
WhmcsChangePackageRequest,
WhmcsPlanCatalogResponse,
WhmcsProvisionServiceRequest,
WhmcsServiceResponse,
WhmcsUpsertClientRequest,
WhmcsUpsertClientResponse,
} from '@hexahost/contracts';
import { formatJoinAddress } from '@hexahost/dns';
import type { GameServer } from '@hexahost/database';
import { BillingService } from '../../billing/billing.service';
import { PrismaService } from '../../prisma/prisma.service';
import { ServersService } from '../../servers/servers.service';
import type { WhmcsInstallationContext } from './whmcs-integration.guard';
type InstallationRef = {
id: string;
integrationId: string;
};
@Injectable()
export class WhmcsIntegrationService {
constructor(
private readonly prisma: PrismaService,
private readonly serversService: ServersService,
private readonly billing: BillingService,
) {}
getHealth(installation: WhmcsInstallationContext) {
return {
status: 'ok' as const,
integrationId: installation.integrationId,
billingProvider: process.env['BILLING_PROVIDER'] ?? 'internal',
};
}
async listPlans(): Promise<WhmcsPlanCatalogResponse> {
const plans = await this.prisma.plan.findMany({
where: { isActive: true },
orderBy: { name: 'asc' },
});
return {
plans: plans.map((plan) => ({
id: plan.id,
slug: plan.slug,
name: plan.name,
maxRamMb: plan.maxRamMb,
isFree: plan.isFree,
})),
};
}
async upsertClient(
installation: InstallationRef,
externalClientId: string,
input: WhmcsUpsertClientRequest,
idempotencyKey: string,
): Promise<WhmcsUpsertClientResponse> {
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
if (existingOp?.result) {
return existingOp.result as WhmcsUpsertClientResponse;
}
const existingLink = await this.prisma.whmcsClientLink.findUnique({
where: {
installationId_externalClientId: {
installationId: installation.id,
externalClientId,
},
},
});
if (existingLink) {
const user = await this.prisma.user.update({
where: { id: existingLink.userId },
data: {
displayName: input.displayName,
},
});
const result = {
userId: user.id,
externalClientId,
created: false,
};
await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result);
return result;
}
const user = await this.prisma.user.create({
data: {
email: input.email,
username: input.username,
displayName: input.displayName,
status: 'ACTIVE',
},
});
await this.billing.ensureWalletForUser(user.id);
await this.prisma.whmcsClientLink.create({
data: {
installationId: installation.id,
externalClientId,
userId: user.id,
},
});
const result = {
userId: user.id,
externalClientId,
created: true,
};
await this.recordOperation(installation.id, idempotencyKey, 'client:upsert', result);
return result;
}
async provisionService(
installation: InstallationRef,
input: WhmcsProvisionServiceRequest,
idempotencyKey: string,
): Promise<WhmcsServiceResponse> {
const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey);
if (existingOp?.result) {
return existingOp.result as WhmcsServiceResponse;
}
const existing = await this.prisma.whmcsServiceLink.findUnique({
where: {
installationId_externalServiceId: {
installationId: installation.id,
externalServiceId: input.externalServiceId,
},
},
include: { server: true },
});
if (existing) {
return this.toServiceResponse(existing.externalServiceId, existing.status, existing.server);
}
const clientLink = await this.prisma.whmcsClientLink.findUnique({
where: {
installationId_externalClientId: {
installationId: installation.id,
externalClientId: input.externalClientId,
},
},
});
if (!clientLink) {
throw new NotFoundException(`Unknown WHMCS client ${input.externalClientId}`);
}
const plan = await this.prisma.plan.findFirst({
where: { slug: input.planSlug, isActive: true },
});
if (!plan) {
throw new NotFoundException(`Unknown plan slug ${input.planSlug}`);
}
await this.billing.assertCanCreateServer(clientLink.userId, plan.id);
const server = await this.serversService.createServer(clientLink.userId, {
name: input.serverName,
edition: input.edition,
softwareFamily: input.softwareFamily,
minecraftVersion: input.minecraftVersion,
eulaAccepted: true,
planId: plan.id,
ramMb: plan.maxRamMb,
});
await this.prisma.whmcsServiceLink.create({
data: {
installationId: installation.id,
externalServiceId: input.externalServiceId,
externalProductId: input.externalProductId,
serverId: server.id,
status: 'PENDING',
},
});
const started = await this.serversService.startServerInternal(
server.id,
`whmcs:provision:${input.externalServiceId}`,
);
await this.prisma.whmcsServiceLink.update({
where: {
installationId_externalServiceId: {
installationId: installation.id,
externalServiceId: input.externalServiceId,
},
},
data: { status: 'ACTIVE' },
});
const response = this.toServiceResponse(
input.externalServiceId,
'ACTIVE',
started.server as unknown as GameServer,
);
await this.recordOperation(
installation.id,
idempotencyKey,
'service:provision',
response,
input.externalServiceId,
);
return response;
}
async getService(
installation: InstallationRef,
externalServiceId: string,
): Promise<WhmcsServiceResponse> {
const link = await this.findServiceLink(installation.id, externalServiceId);
return this.toServiceResponse(externalServiceId, link.status, link.server);
}
async suspendService(
installation: InstallationRef,
externalServiceId: string,
reason: string | undefined,
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 server = link.server;
if (server.status === 'RUNNING') {
await this.serversService.stopServer(server.userId, server.id);
} else if (server.status === 'QUEUED') {
await this.serversService.cancelQueuedStart(server.userId, server.id);
}
const updatedServer = await this.prisma.gameServer.update({
where: { id: server.id },
data: {
billingSuspendedAt: new Date(),
billingSuspendReason: reason ?? 'whmcs-suspend',
},
});
const updatedLink = await this.prisma.whmcsServiceLink.update({
where: { id: link.id },
data: {
status: 'SUSPENDED',
suspendedAt: new Date(),
},
});
const response = this.toServiceResponse(
externalServiceId,
updatedLink.status,
updatedServer,
);
await this.recordOperation(
installation.id,
idempotencyKey,
'service:suspend',
response,
externalServiceId,
);
return response;
}
async unsuspendService(
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 updatedServer = await this.prisma.gameServer.update({
where: { id: link.serverId },
data: {
billingSuspendedAt: null,
billingSuspendReason: null,
},
});
const updatedLink = await this.prisma.whmcsServiceLink.update({
where: { id: link.id },
data: {
status: 'ACTIVE',
suspendedAt: null,
},
});
const response = this.toServiceResponse(
externalServiceId,
updatedLink.status,
updatedServer,
);
await this.recordOperation(
installation.id,
idempotencyKey,
'service:unsuspend',
response,
externalServiceId,
);
return response;
}
async terminateService(
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 server = link.server;
if (server.status === 'RUNNING') {
await this.serversService.stopServer(server.userId, server.id);
}
const updatedServer = await this.prisma.gameServer.update({
where: { id: server.id },
data: {
status: 'DELETED',
billingSuspendedAt: new Date(),
billingSuspendReason: 'whmcs-terminate',
},
});
const updatedLink = await this.prisma.whmcsServiceLink.update({
where: { id: link.id },
data: {
status: 'TERMINATED',
terminatedAt: new Date(),
},
});
const response = this.toServiceResponse(
externalServiceId,
updatedLink.status,
updatedServer,
);
await this.recordOperation(
installation.id,
idempotencyKey,
'service:terminate',
response,
externalServiceId,
);
return response;
}
async changePackage(
installation: InstallationRef,
externalServiceId: string,
input: WhmcsChangePackageRequest,
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 plan = await this.prisma.plan.findFirst({
where: { slug: input.planSlug, isActive: true },
});
if (!plan) {
throw new NotFoundException(`Unknown plan slug ${input.planSlug}`);
}
const updatedServer = await this.prisma.gameServer.update({
where: { id: link.serverId },
data: {
planId: plan.id,
ramMb: input.ramMb ?? plan.maxRamMb,
},
});
const response = this.toServiceResponse(
externalServiceId,
link.status,
updatedServer,
);
await this.recordOperation(
installation.id,
idempotencyKey,
'service:change-package',
response,
externalServiceId,
);
return response;
}
private async findServiceLink(installationId: string, externalServiceId: string) {
const link = await this.prisma.whmcsServiceLink.findUnique({
where: {
installationId_externalServiceId: {
installationId,
externalServiceId,
},
},
include: { server: true },
});
if (!link) {
throw new NotFoundException(`Unknown WHMCS service ${externalServiceId}`);
}
return link;
}
private async findCompletedOperation(installationId: string, idempotencyKey: string) {
return this.prisma.integrationOperation.findUnique({
where: { idempotencyKey },
});
}
private async recordOperation(
installationId: string,
idempotencyKey: string,
operation: string,
result: unknown,
externalRef?: string,
): Promise<void> {
await this.prisma.integrationOperation.create({
data: {
installationId,
idempotencyKey,
operation,
externalRef,
status: 'COMPLETED',
result: result as object,
},
});
}
private toServiceResponse(
externalServiceId: string,
status: 'PENDING' | 'ACTIVE' | 'SUSPENDED' | 'TERMINATED',
server: GameServer,
): WhmcsServiceResponse {
return {
externalServiceId,
status,
server: {
id: server.id,
userId: server.userId,
planId: server.planId,
nodeId: server.nodeId,
name: server.name,
status: server.status,
version: server.version,
edition: server.edition,
softwareFamily: server.softwareFamily,
minecraftVersion: server.minecraftVersion,
eulaAccepted: server.eulaAccepted,
hostPort: server.hostPort,
containerId: server.containerId,
dataPath: server.dataPath,
activeWorldName: server.activeWorldName,
ramMb: server.ramMb,
idleShutdownEnabled: server.idleShutdownEnabled,
idleStopAt: server.idleStopAt?.toISOString() ?? null,
joinSlug: server.joinSlug,
joinHostname: formatJoinAddress(server.joinSlug, server.edition),
joinToStartEnabled: server.joinToStartEnabled,
createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(),
},
};
}
}

View File

@@ -44,6 +44,21 @@ async function bootstrap(): Promise<void> {
await app.register(websocket);
const fastify = app.getHttpAdapter().getInstance();
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'string' },
(request, body, done) => {
try {
const rawBody = typeof body === 'string' ? body : body.toString('utf8');
(request as { rawBody?: string }).rawBody = rawBody;
done(null, rawBody ? JSON.parse(rawBody) : {});
} catch (error) {
done(error as Error, undefined);
}
},
);
const swaggerConfig = new DocumentBuilder()
.setTitle(config.APP_NAME)
.setDescription('HexaHost GameCloud REST API')

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import {
ConflictException,
ForbiddenException,
Inject,
Injectable,
} from '@nestjs/common';
@@ -93,6 +94,7 @@ export class ServersService {
skipStop = false,
): Promise<ServerActionResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.assertServerNotBillingSuspended(server);
this.serverState.assertNotInProgress(server.status);
if (!skipStop && server.status === 'RUNNING') {
@@ -107,6 +109,7 @@ export class ServersService {
serverId: string,
): Promise<ServerActionResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.assertServerNotBillingSuspended(server);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStartTarget(server.status);
@@ -190,6 +193,7 @@ export class ServersService {
throw new ConflictException('Server not found');
}
this.assertServerNotBillingSuspended(server);
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStartTarget(server.status);
@@ -410,6 +414,14 @@ export class ServersService {
return updated;
}
private assertServerNotBillingSuspended(server: GameServer): void {
if (server.billingSuspendedAt) {
throw new ForbiddenException(
server.billingSuspendReason ?? 'Server is suspended by billing',
);
}
}
private async recordTransition(
gameServerId: string,
fromStatus: GameServerStatus | null,

View File

@@ -0,0 +1,29 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('phase 9 api contract', () => {
it('documents WHMCS integration routes and billing suspension', () => {
const whmcsSource = readFileSync(
join(process.cwd(), 'src', 'integrations/whmcs/whmcs-integration.controller.ts'),
'utf8',
);
assert.ok(whmcsSource.includes("'integrations/whmcs'"));
assert.ok(whmcsSource.includes("'services/:externalServiceId/suspend'"));
assert.ok(whmcsSource.includes('WhmcsIntegrationGuard'));
const billingSource = readFileSync(
join(process.cwd(), 'src', 'billing/billing.service.ts'),
'utf8',
);
assert.ok(billingSource.includes('isWhmcsBilling'));
const serversSource = readFileSync(
join(process.cwd(), 'src', 'servers/servers.service.ts'),
'utf8',
);
assert.ok(serversSource.includes('assertServerNotBillingSuspended'));
assert.ok(serversSource.includes('billingSuspendedAt'));
});
});

View File

@@ -0,0 +1,10 @@
import { pushPendingDnsRecords } from '@hexahost/dns';
import { logger } from '../logger';
export async function processDnsSyncTick(): Promise<void> {
const synced = await pushPendingDnsRecords(25);
if (synced > 0) {
logger.info({ synced }, 'DNS records synchronized');
}
}

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -20,4 +20,4 @@ This directory will contain idempotent Ansible automation for game node provisio
## Status
Phase 0 placeholder. Node installation procedures are outlined in `docs/operations/installation.md` until playbooks land in Phase 9.
Phase 9 playbooks provide a baseline for control plane and game node hosts. Extend with environment-specific inventories and secrets management.

View File

@@ -0,0 +1,48 @@
---
- name: Provision control plane
hosts: control_plane
become: true
vars:
gamecloud_user: gamecloud
gamecloud_home: /opt/hexahost-gamecloud
tasks:
- name: Install base packages
ansible.builtin.apt:
name:
- docker.io
- docker-compose-plugin
- postgresql-client
- redis-tools
- bind9-utils
state: present
update_cache: true
- name: Create application user
ansible.builtin.user:
name: "{{ gamecloud_user }}"
system: true
create_home: false
- name: Create application directory
ansible.builtin.file:
path: "{{ gamecloud_home }}"
state: directory
owner: "{{ gamecloud_user }}"
group: "{{ gamecloud_user }}"
mode: "0750"
- name: Deploy compose stack placeholder
ansible.builtin.copy:
dest: "{{ gamecloud_home }}/README.txt"
owner: "{{ gamecloud_user }}"
group: "{{ gamecloud_user }}"
mode: "0640"
content: |
Deploy the production compose stack from deploy/compose on this host.
Required services: PostgreSQL, Redis, MinIO, API, Worker, Web, Edge Gateway.
- name: Remind operator about DNS
ansible.builtin.debug:
msg: >-
Configure DNS_PROVIDER=rfc2136 and RFC2136_* variables on the API/worker hosts.
Join hostnames must point to the edge gateway public IP.

View File

@@ -0,0 +1,62 @@
---
- name: Provision game node
hosts: game_nodes
become: true
vars:
node_agent_user: hgc-node
node_data_dir: /var/lib/hgc
port_range_start: 25565
port_range_end: 25664
tasks:
- name: Install Docker
ansible.builtin.apt:
name:
- docker.io
state: present
update_cache: true
- name: Create node agent user
ansible.builtin.user:
name: "{{ node_agent_user }}"
system: true
create_home: false
- name: Create node data directory
ansible.builtin.file:
path: "{{ node_data_dir }}"
state: directory
owner: "{{ node_agent_user }}"
group: "{{ node_agent_user }}"
mode: "0750"
- name: Open Minecraft host port range
ansible.builtin.ufw:
rule: allow
port: "{{ port_range_start }}:{{ port_range_end }}"
proto: tcp
- name: Install node-agent systemd unit
ansible.builtin.copy:
dest: /etc/systemd/system/hgc-node-agent.service
mode: "0644"
content: |
[Unit]
Description=HexaHost GameCloud Node Agent
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User={{ node_agent_user }}
ExecStart=/usr/local/bin/hgc-node-agent
Restart=on-failure
Environment=NODE_DATA_DIR={{ node_data_dir }}
[Install]
WantedBy=multi-user.target
notify: Reload systemd
handlers:
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true

10
deploy/ansible/site.yml Normal file
View File

@@ -0,0 +1,10 @@
---
- name: HexaHost GameCloud site bootstrap
hosts: all
gather_facts: true
- import_playbook: control-plane.yml
when: "'control_plane' in group_names"
- import_playbook: game-node.yml
when: "'game_nodes' in group_names"

View File

@@ -2,7 +2,50 @@
Last updated: 2026-06-26
## Current phase: Phase 8DNS and join-to-start
## Current phase: Phase 9WHMCS integration and production hardening
### Phase 9 completed
| Area | Status | Notes |
|------|--------|-------|
| WHMCS Prisma models | Done | Installations, client/service links, idempotent operations |
| `@hexahost/integration-auth` | Done | HMAC-SHA256 request signing |
| WHMCS Integration API | Done | `/api/v1/integrations/whmcs/*` with nonce replay protection |
| Billing suspension | Done | `billingSuspendedAt`, blocks start/join-to-start |
| Billing provider mode | Done | `BILLING_PROVIDER=internal\|whmcs` |
| RFC2136 DNS provider | Done | `nsupdate` + worker DNS sync tick |
| WHMCS PHP module | Done | Create/Suspend/Unsuspend/Terminate/ChangePackage |
| Ansible playbooks | Done | `site.yml`, `control-plane.yml`, `game-node.yml` |
| Operations docs | Done | `dns.md`, `control-plane.md`, `game-node.md` |
### Abnahmekriterium Phase 9
- [x] WHMCS can provision/suspend/unsuspend/terminate via signed API
- [x] Production DNS provider abstraction with RFC2136
- [x] Documented control plane + two-node deployment path
- [x] Billing suspension enforced on panel and edge gateway
- [ ] Manual E2E: WHMCS order → GameCloud server → suspend/unsuspend
### Local WHMCS API test
1. `pnpm db:migrate && pnpm db:seed` (creates `local-dev-whmcs` installation)
2. Set `BILLING_PROVIDER=whmcs` optional for credit-bypass mode
3. Sign requests with `WHMCS_API_SECRET` from seed output
4. `PUT /api/v1/integrations/whmcs/clients/1` then `POST .../services`
### WHMCS module install
Copy `integrations/whmcs/modules/servers/hexagamecloud` to `/modules/servers/hexagamecloud` on your WHMCS host.
Server entry fields:
- Hostname: `https://api.example.net`
- Username: integration ID (`WHMCS_INTEGRATION_ID`)
- Password: API secret (`WHMCS_API_SECRET`)
---
## Phase 8 — DNS and join-to-start ✅
### Phase 8 completed
@@ -111,6 +154,6 @@ Last updated: 2026-06-26
## Phase 0 — Monorepo foundation ✅
### Next phase: Phase 9 — WHMCS integration and production hardening
### Next phase: Post-MVP hardening
- Billing provider hooks, WHMCS module, production DNS (RFC2136)
- Stripe webhooks, SSO (WHMCS Phase D), reconciliation dashboard, penetration test

View File

@@ -0,0 +1,69 @@
# Control plane operations
The control plane hosts the customer-facing panel, REST API, background worker, PostgreSQL, Redis, object storage, and the edge gateway.
## Recommended topology (production)
| Host | Role |
|------|------|
| `cp-01` | API, Web, Worker, Edge Gateway |
| `cp-db` | PostgreSQL (managed or dedicated) |
| `cp-cache` | Redis |
| `cp-storage` | S3-compatible object storage |
| `node-01`, `node-02` | Game nodes with node-agent |
Phase 9 acceptance target: **one control plane stack and two game nodes**.
## Core services
- **API** (`apps/api`) — public REST + internal integration endpoints
- **Worker** (`apps/worker`) — queues, idle shutdown, metering, DNS sync
- **Web** (`apps/web`) — customer panel
- **Edge gateway** (`apps/edge-gateway`) — join-to-start TCP proxy
## Billing modes
| Mode | `BILLING_PROVIDER` | Commercial system of record |
|------|--------------------|-----------------------------|
| Internal credits | `internal` | GameCloud wallet |
| WHMCS | `whmcs` | WHMCS invoices/subscriptions |
In WHMCS mode, provisioning and lifecycle are driven by `/api/v1/integrations/whmcs/*` with HMAC authentication.
## Secrets
Never commit production secrets. Rotate:
- `SESSION_SECRET`, `ENCRYPTION_KEY`
- `EDGE_INTERNAL_API_KEY`
- `WHMCS_API_SECRET` (per installation)
- `RFC2136_KEY_SECRET`
- Node enrollment tokens
## Backups
Back up at minimum:
1. PostgreSQL (full daily + WAL)
2. Redis persistence snapshot (if used for non-transient state)
3. Object storage bucket replication
4. `/etc` and deployment configuration on control plane hosts
## Monitoring
See `deploy/monitoring/prometheus.yml` for scrape targets. Alert on:
- API health check failures
- Worker queue depth / failed jobs
- Node heartbeat staleness > 90s
- DNS records in `FAILED` status
## Deployment
Use Ansible playbooks in `deploy/ansible/`:
```bash
ansible-playbook -i inventories/production site.yml
```
Import the production compose stack from `deploy/compose` after base OS provisioning.

48
docs/operations/dns.md Normal file
View File

@@ -0,0 +1,48 @@
# DNS operations
HexaHost GameCloud stores desired DNS records in PostgreSQL (`server_dns_records`) and synchronizes them through a provider abstraction in `@hexahost/dns`.
## Providers
| Provider | `DNS_PROVIDER` | Use case |
|----------|----------------|----------|
| Local metadata | `local` (default) | Development — records exist only in the database |
| RFC2136 dynamic updates | `rfc2136` | Production with BIND or compatible authoritative DNS |
## Production (RFC2136)
Set on API and worker hosts:
```env
DNS_PROVIDER=rfc2136
GAME_BASE_DOMAIN=play.example.net
EDGE_PUBLIC_HOST=203.0.113.10
EDGE_LISTEN_PORT=25565
RFC2136_SERVER=ns1.internal.example.net
RFC2136_KEY_NAME=gamecloud-updater.
RFC2136_KEY_SECRET=base64-encoded-secret
RFC2136_TTL=300
```
Requirements:
- `nsupdate` (bind9-utils) installed on API/worker hosts
- TSIG key authorized for A and SRV updates on `*.play.example.net`
- Edge gateway reachable at `EDGE_PUBLIC_HOST:EDGE_LISTEN_PORT`
## Record layout
For each Java server with join slug `my-server-abc123`:
- `A` record: `my-server-abc123.play.example.net` → edge gateway IP
- `SRV` record: `_minecraft._tcp.my-server-abc123.play.example.net` → edge hostname/port
## Worker sync
The worker runs a DNS sync tick every 60 seconds (`pushPendingDnsRecords`). Records start as `PENDING` when `DNS_PROVIDER=rfc2136` and move to `ACTIVE` or `FAILED`.
## Troubleshooting
1. Check `server_dns_records.status` and `lastError` in PostgreSQL.
2. Run `nsupdate -y` manually with the generated script from logs.
3. Verify TSIG key name ends with `.` if your nameserver expects FQDN form.

View File

@@ -0,0 +1,63 @@
# Game node operations
Game nodes run Docker workloads for Minecraft servers and connect to the control plane through the node-agent.
## Capacity
Each node exposes:
- `maxServers`, `maxRamMb`, `platformRamMb`
- Host port pool: `portRangeStart``portRangeEnd` (default `25565``25664`)
The scheduler places new servers on the least-loaded eligible node.
## Enrollment
1. Create a `GameNode` row in the control plane database.
2. Generate an enrollment token and distribute it securely to the node host.
3. Start `node-agent` with matching `NODE_ID` and `NODE_TOKEN`.
4. Confirm node status becomes `ONLINE` in the admin API.
## Port allocation
Host ports are unique per node (`@@unique([nodeId, hostPort])`). Do not manually assign ports outside the configured range.
## Draining
Before maintenance:
1. Set node status to `DRAINING` via admin API.
2. Wait for running servers to stop or migrate.
3. Perform maintenance.
4. Return node to `ONLINE` or decommission.
## Firewall
Allow:
- Outbound WebSocket to control plane API
- Inbound TCP on allocated Minecraft host port range
- Docker bridge traffic locally
Deny:
- Public access to Docker API
- Unrestricted inbound on management ports
## Ansible
Bootstrap with:
```bash
ansible-playbook -i inventories/production deploy/ansible/game-node.yml
```
Copy the `hgc-node-agent` binary to `/usr/local/bin/hgc-node-agent` and provide TLS materials via `NODE_TLS_CERT` / `NODE_TLS_KEY` or CA enrollment.
## Failure scenarios
| Scenario | Action |
|----------|--------|
| Node offline | Servers marked `UNKNOWN`; reschedule starts when node returns |
| Port exhaustion | Provisioning fails with `NO_NODE_AVAILABLE`; add node or expand port range |
| Disk full | Stop new provisions; prune unused images; expand volume |

View File

@@ -52,7 +52,10 @@ Handles global configuration:
## Status
**Phase 0:** Directory placeholders only. Implementation follows MVP Phase 9 and WHMCS phases AF documented in the master specification.
**Phase 9 (WHMCS Phase AB + production DNS)** — implemented.
- Server module: `integrations/whmcs/modules/servers/hexagamecloud/`
- Addon module: planned for global mappings and reconciliation UI
## Documentation (planned)

View File

@@ -0,0 +1,144 @@
<?php
if (!defined('WHMCS')) {
die('This file cannot be accessed directly');
}
require_once __DIR__ . '/lib/ApiClient.php';
function hexagamecloud_MetaData(): array
{
return [
'DisplayName' => 'HexaHost GameCloud',
'APIVersion' => '1.1',
'RequiresServer' => true,
'DefaultNonSSLPort' => '443',
'DefaultSSLPort' => '443',
];
}
function hexagamecloud_ConfigOptions(): array
{
return [
'plan_slug' => [
'FriendlyName' => 'GameCloud Plan Slug',
'Type' => 'text',
'Size' => '32',
'Default' => 'free',
'Description' => 'Maps to a GameCloud plan slug',
],
'minecraft_version' => [
'FriendlyName' => 'Minecraft Version',
'Type' => 'text',
'Size' => '16',
'Default' => '1.21.1',
],
'software_family' => [
'FriendlyName' => 'Software Family',
'Type' => 'dropdown',
'Options' => 'VANILLA,PAPER,PURPUR,FABRIC,FORGE,NEOFORGE,QUILT',
'Default' => 'VANILLA',
],
];
}
function hexagamecloud_CreateAccount(array $params): string
{
try {
$client = new HexaGameCloudApiClient($params);
$externalClientId = (string) $params['clientsdetails']['userid'];
$externalServiceId = (string) $params['serviceid'];
$client->upsertClient($externalClientId, [
'email' => $params['clientsdetails']['email'],
'username' => $params['clientsdetails']['email'],
'displayName' => trim(($params['clientsdetails']['firstname'] ?? '') . ' ' . ($params['clientsdetails']['lastname'] ?? '')),
]);
$response = $client->provisionService([
'externalServiceId' => $externalServiceId,
'externalClientId' => $externalClientId,
'externalProductId' => (string) ($params['pid'] ?? ''),
'planSlug' => $params['configoption1'] ?: 'free',
'serverName' => $params['domain'] ?: ('server-' . $externalServiceId),
'minecraftVersion' => $params['configoption2'] ?: '1.21.1',
'softwareFamily' => $params['configoption3'] ?: 'VANILLA',
'edition' => 'JAVA',
'eulaAccepted' => true,
]);
return 'success';
} catch (Throwable $exception) {
logModuleCall(
'hexagamecloud',
__FUNCTION__,
$params,
$exception->getMessage(),
$exception->getMessage(),
['password', 'secret', 'apiSecret']
);
return $exception->getMessage();
}
}
function hexagamecloud_SuspendAccount(array $params): string
{
return hexagamecloud_lifecycleAction($params, 'suspend', ['reason' => 'Suspended in WHMCS']);
}
function hexagamecloud_UnsuspendAccount(array $params): string
{
return hexagamecloud_lifecycleAction($params, 'unsuspend');
}
function hexagamecloud_TerminateAccount(array $params): string
{
return hexagamecloud_lifecycleAction($params, 'terminate');
}
function hexagamecloud_ChangePackage(array $params): string
{
try {
$client = new HexaGameCloudApiClient($params);
$client->changePackage((string) $params['serviceid'], [
'planSlug' => $params['configoption1'] ?: 'free',
]);
return 'success';
} catch (Throwable $exception) {
logModuleCall('hexagamecloud', __FUNCTION__, $params, $exception->getMessage());
return $exception->getMessage();
}
}
function hexagamecloud_TestConnection(array $params): array
{
try {
$client = new HexaGameCloudApiClient($params);
$health = $client->health();
return [
'success' => true,
'error' => 'Connected to ' . ($health['integrationId'] ?? 'GameCloud'),
];
} catch (Throwable $exception) {
return ['success' => false, 'error' => $exception->getMessage()];
}
}
function hexagamecloud_lifecycleAction(array $params, string $action, array $body = []): string
{
try {
$client = new HexaGameCloudApiClient($params);
$serviceId = (string) $params['serviceid'];
if ($action === 'suspend') {
$client->suspendService($serviceId, $body);
} elseif ($action === 'unsuspend') {
$client->unsuspendService($serviceId);
} else {
$client->terminateService($serviceId);
}
return 'success';
} catch (Throwable $exception) {
logModuleCall('hexagamecloud', $action, $params, $exception->getMessage());
return $exception->getMessage();
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
final class HexaGameCloudApiClient
{
private string $baseUrl;
private string $integrationId;
private string $apiSecret;
public function __construct(array $params)
{
$host = rtrim((string) ($params['serverhostname'] ?? ''), '/');
$this->baseUrl = $host;
$this->integrationId = (string) ($params['serverusername'] ?? '');
$this->apiSecret = (string) ($params['serverpassword'] ?? '');
if ($this->baseUrl === '' || $this->integrationId === '' || $this->apiSecret === '') {
throw new RuntimeException('GameCloud server hostname, integration ID and API secret are required');
}
}
public function health(): array
{
return $this->request('GET', '/api/v1/integrations/whmcs/health');
}
public function upsertClient(string $externalClientId, array $payload): array
{
return $this->request(
'PUT',
'/api/v1/integrations/whmcs/clients/' . rawurlencode($externalClientId),
$payload,
'client:' . $externalClientId
);
}
public function provisionService(array $payload): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services',
$payload,
'service:provision:' . $payload['externalServiceId']
);
}
public function suspendService(string $externalServiceId, array $payload = []): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/suspend',
$payload,
'service:suspend:' . $externalServiceId
);
}
public function unsuspendService(string $externalServiceId): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/unsuspend',
[],
'service:unsuspend:' . $externalServiceId
);
}
public function terminateService(string $externalServiceId): array
{
return $this->request(
'POST',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/terminate',
[],
'service:terminate:' . $externalServiceId
);
}
public function changePackage(string $externalServiceId, array $payload): array
{
return $this->request(
'PATCH',
'/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/change-package',
$payload,
'service:change-package:' . $externalServiceId
);
}
private function request(string $method, string $path, array $body = [], ?string $idempotencySuffix = null): array
{
$bodyJson = $body === [] ? '' : json_encode($body, JSON_THROW_ON_ERROR);
$timestamp = (string) (int) (microtime(true) * 1000);
$nonce = bin2hex(random_bytes(16));
$idempotencyKey = $idempotencySuffix ?: $method . ':' . $path . ':' . $timestamp;
$signatureBase = implode("\n", [
strtoupper($method),
$path,
'',
hash('sha256', $bodyJson),
$timestamp,
$nonce,
$idempotencyKey,
]);
$signature = hash_hmac('sha256', $signatureBase, $this->apiSecret);
$ch = curl_init($this->baseUrl . $path);
if ($ch === false) {
throw new RuntimeException('Unable to initialize HTTP client');
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Accept: application/json',
'X-HGC-Integration-ID: ' . $this->integrationId,
'X-HGC-Timestamp: ' . $timestamp,
'X-HGC-Nonce: ' . $nonce,
'X-HGC-Idempotency-Key: ' . $idempotencyKey,
'X-HGC-Signature: ' . $signature,
],
CURLOPT_POSTFIELDS => $bodyJson,
]);
$responseBody = curl_exec($ch);
$statusCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($responseBody === false) {
throw new RuntimeException('GameCloud request failed: ' . $curlError);
}
$decoded = json_decode($responseBody, true);
if ($statusCode >= 400) {
$message = is_array($decoded)
? ($decoded['detail'] ?? $decoded['title'] ?? $responseBody)
: $responseBody;
throw new RuntimeException('GameCloud API error (' . $statusCode . '): ' . $message);
}
return is_array($decoded) ? $decoded : [];
}
}

View File

@@ -218,3 +218,21 @@ export {
type EdgeStartRequest,
type EdgeStartResponse,
} from './join';
export {
whmcsHealthResponseSchema,
whmcsPlanCatalogResponseSchema,
whmcsUpsertClientRequestSchema,
whmcsUpsertClientResponseSchema,
whmcsProvisionServiceRequestSchema,
whmcsServiceResponseSchema,
whmcsChangePackageRequestSchema,
whmcsSuspendRequestSchema,
type WhmcsHealthResponse,
type WhmcsPlanCatalogResponse,
type WhmcsProvisionServiceRequest,
type WhmcsServiceResponse,
type WhmcsUpsertClientRequest,
type WhmcsUpsertClientResponse,
type WhmcsChangePackageRequest,
} from './whmcs';

View File

@@ -0,0 +1,71 @@
import { z } from 'zod';
import { serverResponseSchema } from './servers';
export const whmcsHealthResponseSchema = z.object({
status: z.literal('ok'),
integrationId: z.string(),
billingProvider: z.string(),
});
export const whmcsPlanCatalogItemSchema = z.object({
id: z.string().uuid(),
slug: z.string(),
name: z.string(),
maxRamMb: z.number().int(),
isFree: z.boolean(),
});
export const whmcsPlanCatalogResponseSchema = z.object({
plans: z.array(whmcsPlanCatalogItemSchema),
});
export const whmcsUpsertClientRequestSchema = z.object({
email: z.string().email(),
username: z.string().min(3).max(32),
displayName: z.string().max(64).optional(),
});
export const whmcsUpsertClientResponseSchema = z.object({
userId: z.string().uuid(),
externalClientId: z.string(),
created: z.boolean(),
});
export const whmcsProvisionServiceRequestSchema = z.object({
externalServiceId: z.string().min(1).max(64),
externalClientId: z.string().min(1).max(64),
externalProductId: z.string().max(64).optional(),
planSlug: z.string().min(1).max(64),
serverName: z.string().min(1).max(64),
minecraftVersion: z.string().min(1).max(32).default('1.21.1'),
softwareFamily: z
.enum(['VANILLA', 'PAPER', 'PURPUR', 'FABRIC', 'FORGE', 'NEOFORGE', 'QUILT', 'BEDROCK_DEDICATED'])
.default('VANILLA'),
edition: z.enum(['JAVA', 'BEDROCK']).default('JAVA'),
eulaAccepted: z.literal(true),
});
export const whmcsServiceResponseSchema = z.object({
externalServiceId: z.string(),
status: z.enum(['PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED']),
server: serverResponseSchema,
});
export const whmcsChangePackageRequestSchema = z.object({
planSlug: z.string().min(1).max(64),
ramMb: z.number().int().min(512).max(65536).optional(),
});
export const whmcsSuspendRequestSchema = z.object({
reason: z.string().max(256).optional(),
});
export type WhmcsHealthResponse = z.infer<typeof whmcsHealthResponseSchema>;
export type WhmcsPlanCatalogResponse = z.infer<typeof whmcsPlanCatalogResponseSchema>;
export type WhmcsUpsertClientRequest = z.infer<typeof whmcsUpsertClientRequestSchema>;
export type WhmcsUpsertClientResponse = z.infer<typeof whmcsUpsertClientResponseSchema>;
export type WhmcsProvisionServiceRequest = z.infer<typeof whmcsProvisionServiceRequestSchema>;
export type WhmcsServiceResponse = z.infer<typeof whmcsServiceResponseSchema>;
export type WhmcsChangePackageRequest = z.infer<typeof whmcsChangePackageRequestSchema>;
export type WhmcsSuspendRequest = z.infer<typeof whmcsSuspendRequestSchema>;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,94 @@
-- Phase 9: WHMCS integration and billing suspension
CREATE TYPE "WhmcsServiceStatus" AS ENUM ('PENDING', 'ACTIVE', 'SUSPENDED', 'TERMINATED');
CREATE TYPE "IntegrationOperationStatus" AS ENUM ('COMPLETED', 'FAILED');
ALTER TABLE "game_servers"
ADD COLUMN IF NOT EXISTS "billingSuspendedAt" TIMESTAMPTZ(3),
ADD COLUMN IF NOT EXISTS "billingSuspendReason" TEXT;
CREATE TABLE IF NOT EXISTS "whmcs_installations" (
"id" TEXT NOT NULL,
"integrationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"apiSecret" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"metadata" JSONB,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "whmcs_installations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_installations_integrationId_key"
ON "whmcs_installations"("integrationId");
CREATE TABLE IF NOT EXISTS "whmcs_client_links" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"externalClientId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "whmcs_client_links_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_client_links_userId_key" ON "whmcs_client_links"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_client_links_installationId_externalClientId_key"
ON "whmcs_client_links"("installationId", "externalClientId");
CREATE TABLE IF NOT EXISTS "whmcs_service_links" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"externalServiceId" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"externalProductId" TEXT,
"status" "WhmcsServiceStatus" NOT NULL DEFAULT 'PENDING',
"suspendedAt" TIMESTAMPTZ(3),
"terminatedAt" TIMESTAMPTZ(3),
"metadata" JSONB,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "whmcs_service_links_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_service_links_serverId_key" ON "whmcs_service_links"("serverId");
CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_service_links_installationId_externalServiceId_key"
ON "whmcs_service_links"("installationId", "externalServiceId");
CREATE INDEX IF NOT EXISTS "whmcs_service_links_installationId_status_idx"
ON "whmcs_service_links"("installationId", "status");
CREATE TABLE IF NOT EXISTS "integration_operations" (
"id" TEXT NOT NULL,
"installationId" TEXT NOT NULL,
"idempotencyKey" TEXT NOT NULL,
"operation" TEXT NOT NULL,
"externalRef" TEXT,
"status" "IntegrationOperationStatus" NOT NULL DEFAULT 'COMPLETED',
"result" JSONB,
"error" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "integration_operations_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "integration_operations_idempotencyKey_key"
ON "integration_operations"("idempotencyKey");
CREATE INDEX IF NOT EXISTS "integration_operations_installationId_createdAt_idx"
ON "integration_operations"("installationId", "createdAt");
ALTER TABLE "whmcs_client_links"
ADD CONSTRAINT "whmcs_client_links_installationId_fkey"
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "whmcs_client_links"
ADD CONSTRAINT "whmcs_client_links_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "whmcs_service_links"
ADD CONSTRAINT "whmcs_service_links_installationId_fkey"
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "whmcs_service_links"
ADD CONSTRAINT "whmcs_service_links_serverId_fkey"
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "integration_operations"
ADD CONSTRAINT "integration_operations_installationId_fkey"
FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -135,6 +135,18 @@ enum DnsRecordStatus {
REMOVED
}
enum WhmcsServiceStatus {
PENDING
ACTIVE
SUSPENDED
TERMINATED
}
enum IntegrationOperationStatus {
COMPLETED
FAILED
}
model User {
id String @id @default(uuid())
email String @unique
@@ -156,6 +168,7 @@ model User {
passwordResetTokens PasswordResetToken[]
totpCredential TotpCredential?
creditWallet CreditWallet?
whmcsClientLink WhmcsClientLink?
@@map("users")
}
@@ -300,6 +313,8 @@ model GameServer {
lastMeteredAt DateTime? @db.Timestamptz(3)
joinSlug String? @unique
joinToStartEnabled Boolean @default(true)
billingSuspendedAt DateTime? @db.Timestamptz(3)
billingSuspendReason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -316,6 +331,7 @@ model GameServer {
startQueueEntry ServerStartQueueEntry?
usageRecords UsageRecord[]
dnsRecords ServerDnsRecord[]
whmcsServiceLink WhmcsServiceLink?
@@index([userId])
@@index([nodeId])
@@ -684,3 +700,73 @@ model ServerDnsRecord {
@@index([fqdn])
@@map("server_dns_records")
}
model WhmcsInstallation {
id String @id @default(uuid())
integrationId String @unique
name String
apiSecret String
isActive Boolean @default(true)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
clientLinks WhmcsClientLink[]
serviceLinks WhmcsServiceLink[]
operations IntegrationOperation[]
@@map("whmcs_installations")
}
model WhmcsClientLink {
id String @id @default(uuid())
installationId String
externalClientId String
userId String @unique
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([installationId, externalClientId])
@@map("whmcs_client_links")
}
model WhmcsServiceLink {
id String @id @default(uuid())
installationId String
externalServiceId String
serverId String @unique
externalProductId String?
status WhmcsServiceStatus @default(PENDING)
suspendedAt DateTime? @db.Timestamptz(3)
terminatedAt DateTime? @db.Timestamptz(3)
metadata Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([installationId, externalServiceId])
@@index([installationId, status])
@@map("whmcs_service_links")
}
model IntegrationOperation {
id String @id @default(uuid())
installationId String
idempotencyKey String @unique
operation String
externalRef String?
status IntegrationOperationStatus @default(COMPLETED)
result Json?
error String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
@@index([installationId, createdAt])
@@map("integration_operations")
}

View File

@@ -14,6 +14,11 @@ export const DEV_NODE_ENROLLMENT_TOKEN =
export const DEV_NODE_2_ENROLLMENT_TOKEN =
'local-dev-node2-enrollment-token-32ch';
export const DEV_WHMCS_INTEGRATION_ID = 'local-dev-whmcs';
export const DEV_WHMCS_API_SECRET =
'local-dev-whmcs-api-secret-32chars-minimum';
async function main(): Promise<void> {
const prisma = new PrismaClient();
@@ -93,12 +98,28 @@ async function main(): Promise<void> {
},
});
const whmcsInstallation = await prisma.whmcsInstallation.upsert({
where: { integrationId: DEV_WHMCS_INTEGRATION_ID },
create: {
integrationId: DEV_WHMCS_INTEGRATION_ID,
name: 'Local Development',
apiSecret: DEV_WHMCS_API_SECRET,
isActive: true,
},
update: {
apiSecret: DEV_WHMCS_API_SECRET,
isActive: true,
},
});
console.log('Seed complete:');
console.log(` Plan Free: ${freePlan.id}`);
console.log(` Dev node: ${devNode.id} (${devNode.name})`);
console.log(` Dev node 2: ${devNode2.id} (${devNode2.name})`);
console.log(` Enrollment token (set NODE_TOKEN): ${DEV_NODE_ENROLLMENT_TOKEN}`);
console.log(` Node 2 enrollment token: ${DEV_NODE_2_ENROLLMENT_TOKEN}`);
console.log(` WHMCS integration: ${whmcsInstallation.integrationId}`);
console.log(` WHMCS API secret: ${DEV_WHMCS_API_SECRET}`);
} finally {
await prisma.$disconnect();
}

View File

@@ -24,6 +24,10 @@ export type {
CreditWallet,
CreditTransaction,
UsageRecord,
WhmcsInstallation,
WhmcsClientLink,
WhmcsServiceLink,
IntegrationOperation,
Plan,
FeatureFlag,
SystemSetting,

File diff suppressed because one or more lines are too long

View File

@@ -5,5 +5,14 @@ export {
getEdgeListenPort,
getEdgePublicHost,
getPlayDomain,
pushPendingDnsRecords,
syncServerDnsRecords,
} from './sync';
export {
createDnsProvider,
resetDnsProviderCache,
resolveDnsProviderName,
type DnsProvider,
type DnsProviderName,
type DnsRecordSpec,
} from './providers';

View File

@@ -0,0 +1,25 @@
import { LocalDnsProvider } from './local';
import { Rfc2136DnsProvider } from './rfc2136';
import { resolveDnsProviderName, type DnsProvider } from './types';
let cachedProvider: DnsProvider | undefined;
export function createDnsProvider(): DnsProvider {
if (cachedProvider) {
return cachedProvider;
}
cachedProvider =
resolveDnsProviderName() === 'rfc2136'
? new Rfc2136DnsProvider()
: new LocalDnsProvider();
return cachedProvider;
}
export function resetDnsProviderCache(): void {
cachedProvider = undefined;
}
export type { DnsProvider, DnsProviderName, DnsRecordSpec } from './types';
export { resolveDnsProviderName } from './types';

View File

@@ -0,0 +1,13 @@
import type { DnsProvider, DnsRecordSpec } from './types';
export class LocalDnsProvider implements DnsProvider {
readonly name = 'local';
async syncRecord(_record: DnsRecordSpec): Promise<void> {
// Metadata-only in development; records are stored in PostgreSQL.
}
async removeRecord(_record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void> {
// No external DNS in local mode.
}
}

View File

@@ -0,0 +1,95 @@
import { spawn } from 'node:child_process';
import type { DnsProvider, DnsRecordSpec } from './types';
function getRfc2136Config() {
return {
server: process.env['RFC2136_SERVER'] ?? '',
keyName: process.env['RFC2136_KEY_NAME'] ?? '',
keySecret: process.env['RFC2136_KEY_SECRET'] ?? '',
ttl: Number.parseInt(process.env['RFC2136_TTL'] ?? '300', 10),
};
}
function buildNsupdateScript(record: DnsRecordSpec, mode: 'add' | 'delete'): string {
const config = getRfc2136Config();
const ttl = record.ttl ?? config.ttl;
const lines = [`server ${config.server}`, `key hmac-md5:${config.keyName} ${config.keySecret}`];
if (mode === 'delete') {
if (record.recordType === 'SRV') {
lines.push(`update delete ${record.fqdn} SRV`);
} else {
lines.push(`update delete ${record.fqdn} A`);
}
} else if (record.recordType === 'SRV') {
const priority = record.srvPriority ?? 0;
const weight = record.srvWeight ?? 5;
const port = record.port ?? 25565;
const target = record.target.endsWith('.') ? record.target : `${record.target}.`;
lines.push(
`update add ${record.fqdn} ${ttl} SRV ${priority} ${weight} ${port} ${target}`,
);
} else {
lines.push(`update add ${record.fqdn} ${ttl} A ${record.target}`);
}
lines.push('send');
return lines.join('\n');
}
async function runNsupdate(script: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn('nsupdate', ['-v'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let stderr = '';
child.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8');
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr.trim() || `nsupdate exited with code ${code}`));
});
child.stdin.write(script);
child.stdin.end();
});
}
export class Rfc2136DnsProvider implements DnsProvider {
readonly name = 'rfc2136';
private assertConfigured(): void {
const config = getRfc2136Config();
if (!config.server || !config.keyName || !config.keySecret) {
throw new Error(
'RFC2136 DNS provider requires RFC2136_SERVER, RFC2136_KEY_NAME and RFC2136_KEY_SECRET',
);
}
}
async syncRecord(record: DnsRecordSpec): Promise<void> {
this.assertConfigured();
const script = buildNsupdateScript(record, 'add');
await runNsupdate(script);
}
async removeRecord(record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void> {
this.assertConfigured();
const script = buildNsupdateScript(
{
serverId: 'remove',
fqdn: record.fqdn,
recordType: record.recordType,
target: '0.0.0.0',
},
'delete',
);
await runNsupdate(script);
}
}

View File

@@ -0,0 +1,23 @@
export interface DnsRecordSpec {
serverId: string;
fqdn: string;
recordType: 'A' | 'SRV';
target: string;
port?: number;
srvPriority?: number;
srvWeight?: number;
ttl?: number;
}
export interface DnsProvider {
readonly name: string;
syncRecord(record: DnsRecordSpec): Promise<void>;
removeRecord(record: Pick<DnsRecordSpec, 'fqdn' | 'recordType'>): Promise<void>;
}
export type DnsProviderName = 'local' | 'rfc2136';
export function resolveDnsProviderName(): DnsProviderName {
const value = (process.env['DNS_PROVIDER'] ?? 'local').toLowerCase();
return value === 'rfc2136' ? 'rfc2136' : 'local';
}

View File

@@ -1,6 +1,8 @@
import { prisma } from '@hexahost/database';
import type { GameNode, GameServer } from '@hexahost/database';
import { createDnsProvider, resolveDnsProviderName } from './providers';
import type { DnsRecordSpec } from './providers';
import { buildJoinSlug } from './slug';
export function getPlayDomain(): string {
@@ -21,6 +23,10 @@ function joinHostname(slug: string, playDomain = getPlayDomain()): string {
return `${slug}.${playDomain}`;
}
function initialDnsStatus(): 'PENDING' | 'ACTIVE' {
return resolveDnsProviderName() === 'rfc2136' ? 'PENDING' : 'ACTIVE';
}
async function upsertDnsRecord(input: {
serverId: string;
fqdn: string;
@@ -30,6 +36,8 @@ async function upsertDnsRecord(input: {
srvPriority?: number;
srvWeight?: number;
}): Promise<void> {
const status = initialDnsStatus();
await prisma.serverDnsRecord.upsert({
where: {
serverId_recordType_fqdn: {
@@ -46,19 +54,75 @@ async function upsertDnsRecord(input: {
port: input.port,
srvPriority: input.srvPriority,
srvWeight: input.srvWeight,
status: 'ACTIVE',
lastSyncedAt: new Date(),
status,
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
},
update: {
target: input.target,
port: input.port,
srvPriority: input.srvPriority,
srvWeight: input.srvWeight,
status,
lastSyncedAt: status === 'ACTIVE' ? new Date() : null,
lastError: null,
},
});
}
function toDnsRecordSpec(record: {
serverId: string;
fqdn: string;
recordType: string;
target: string;
port: number | null;
srvPriority: number | null;
srvWeight: number | null;
}): DnsRecordSpec {
return {
serverId: record.serverId,
fqdn: record.fqdn,
recordType: record.recordType as 'A' | 'SRV',
target: record.target,
port: record.port ?? undefined,
srvPriority: record.srvPriority ?? undefined,
srvWeight: record.srvWeight ?? undefined,
};
}
export async function pushPendingDnsRecords(limit = 50): Promise<number> {
const provider = createDnsProvider();
const pending = await prisma.serverDnsRecord.findMany({
where: { status: 'PENDING' },
take: limit,
orderBy: { updatedAt: 'asc' },
});
let synced = 0;
for (const record of pending) {
try {
await provider.syncRecord(toDnsRecordSpec(record));
await prisma.serverDnsRecord.update({
where: { id: record.id },
data: {
status: 'ACTIVE',
lastSyncedAt: new Date(),
lastError: null,
},
});
synced += 1;
} catch (error) {
await prisma.serverDnsRecord.update({
where: { id: record.id },
data: {
status: 'FAILED',
lastError: error instanceof Error ? error.message : 'DNS sync failed',
},
});
}
}
return synced;
}
export async function syncServerDnsRecords(
@@ -91,6 +155,12 @@ export async function syncServerDnsRecords(
srvWeight: 5,
});
}
if (resolveDnsProviderName() === 'local') {
return;
}
await pushPendingDnsRecords(10);
}
async function ensureUniqueSlug(serverId: string, serverName: string): Promise<string> {

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/integration-auth",
"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,25 @@
import { describe, expect, it } from 'vitest';
import { signRequest, verifyRequestSignature } from './hmac';
describe('integration HMAC', () => {
it('signs and verifies canonical requests', () => {
const parts = {
method: 'POST',
path: '/api/v1/integrations/whmcs/services',
query: '',
body: '{"externalServiceId":"42"}',
timestamp: '1710000000000',
nonce: 'nonce-1',
idempotencyKey: 'idem-1',
};
const signature = signRequest('test-secret', parts);
expect(
verifyRequestSignature('test-secret', parts, signature, {
now: 1_710_000_000_000,
}),
).toBe(true);
expect(verifyRequestSignature('wrong', parts, signature)).toBe(false);
});
});

View File

@@ -0,0 +1,81 @@
import { createHmac, timingSafeEqual, createHash } from 'node:crypto';
export const INTEGRATION_HEADERS = {
integrationId: 'x-hgc-integration-id',
timestamp: 'x-hgc-timestamp',
nonce: 'x-hgc-nonce',
idempotencyKey: 'x-hgc-idempotency-key',
signature: 'x-hgc-signature',
correlationId: 'x-correlation-id',
} as const;
export interface SignedRequestParts {
method: string;
path: string;
query: string;
body: string;
timestamp: string;
nonce: string;
idempotencyKey: string;
}
export function hashBody(body: string): string {
return createHash('sha256').update(body).digest('hex');
}
export function buildSignatureBase(parts: SignedRequestParts): string {
return [
parts.method.toUpperCase(),
parts.path,
parts.query,
hashBody(parts.body),
parts.timestamp,
parts.nonce,
parts.idempotencyKey,
].join('\n');
}
export function signRequest(secret: string, parts: SignedRequestParts): string {
const base = buildSignatureBase(parts);
return createHmac('sha256', secret).update(base).digest('hex');
}
export function verifyRequestSignature(
secret: string,
parts: SignedRequestParts,
providedSignature: string,
options?: { maxSkewMs?: number; now?: number },
): boolean {
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
const now = options?.now ?? Date.now();
const timestampMs = Number.parseInt(parts.timestamp, 10);
if (!Number.isFinite(timestampMs)) {
return false;
}
if (Math.abs(now - timestampMs) > maxSkewMs) {
return false;
}
const expected = signRequest(secret, parts);
const expectedBuffer = Buffer.from(expected, 'utf8');
const providedBuffer = Buffer.from(providedSignature, 'utf8');
if (expectedBuffer.length !== providedBuffer.length) {
return false;
}
return timingSafeEqual(expectedBuffer, providedBuffer);
}
export function canonicalQuery(query: Record<string, string | string[] | undefined>): string {
return Object.entries(query)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => {
const normalized = Array.isArray(value) ? value[0] : value;
return `${encodeURIComponent(key)}=${encodeURIComponent(normalized ?? '')}`;
})
.sort()
.join('&');
}

View File

@@ -0,0 +1,9 @@
export {
INTEGRATION_HEADERS,
buildSignatureBase,
canonicalQuery,
hashBody,
signRequest,
verifyRequestSignature,
type SignedRequestParts,
} from './hmac';

View File

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

18
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
'@hexahost/dns':
specifier: workspace:*
version: link:../../packages/dns
'@hexahost/integration-auth':
specifier: workspace:*
version: link:../../packages/integration-auth
'@hexahost/metering':
specifier: workspace:*
version: link:../../packages/metering
@@ -352,6 +355,21 @@ importers:
packages/eslint-config: {}
packages/integration-auth:
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/metering:
devDependencies:
'@hexahost/typescript-config':