Phase9
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
9
apps/api/src/integrations/integrations.module.ts
Normal file
9
apps/api/src/integrations/integrations.module.ts
Normal 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 {}
|
||||
@@ -0,0 +1 @@
|
||||
export const INTEGRATIONS_REDIS = Symbol('INTEGRATIONS_REDIS');
|
||||
135
apps/api/src/integrations/whmcs/whmcs-integration.controller.ts
Normal file
135
apps/api/src/integrations/whmcs/whmcs-integration.controller.ts
Normal 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;
|
||||
}
|
||||
97
apps/api/src/integrations/whmcs/whmcs-integration.guard.ts
Normal file
97
apps/api/src/integrations/whmcs/whmcs-integration.guard.ts
Normal 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;
|
||||
}
|
||||
37
apps/api/src/integrations/whmcs/whmcs-integration.module.ts
Normal file
37
apps/api/src/integrations/whmcs/whmcs-integration.module.ts
Normal 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 {}
|
||||
509
apps/api/src/integrations/whmcs/whmcs-integration.service.ts
Normal file
509
apps/api/src/integrations/whmcs/whmcs-integration.service.ts
Normal 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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
29
apps/api/test/phase9.test.js
Normal file
29
apps/api/test/phase9.test.js
Normal 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'));
|
||||
});
|
||||
});
|
||||
10
apps/worker/src/handlers/dns-sync.ts
Normal file
10
apps/worker/src/handlers/dns-sync.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user