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

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