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

This commit is contained in:
smueller
2026-06-26 13:00:01 +02:00
parent 4262464cd5
commit d29a02f2a4
93 changed files with 1875 additions and 55 deletions

View File

@@ -9,13 +9,14 @@
"start:prod": "node dist/main.js",
"lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit",
"test": "tsc && node --test test/**/*.test.js"
"test": "tsc -p tsconfig.build.json && node --test test/**/*.test.js"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.4",
"@fastify/websocket": "^11.0.2",
"@hexahost/auth": "workspace:*",
"@hexahost/catalog": "workspace:*",
"@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*",

View File

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

View File

@@ -0,0 +1,67 @@
import { BadRequestException, Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { catalogSearchQuerySchema } from '@hexahost/contracts';
import { SessionGuard } from '../auth/guards/session.guard';
import { PrismaService } from '../prisma/prisma.service';
import { CatalogService } from './catalog.service';
@ApiTags('catalog')
@Controller('catalog')
@UseGuards(SessionGuard)
export class CatalogController {
constructor(
private readonly catalogService: CatalogService,
private readonly prisma: PrismaService,
) {}
@Get('projects')
@ApiOperation({ summary: 'Search Modrinth projects' })
search(@Query() query: Record<string, string | undefined>) {
const input = catalogSearchQuerySchema.parse({
q: query['q'],
gameVersion: query['gameVersion'],
softwareFamily: query['softwareFamily'],
limit: query['limit'],
});
return this.catalogService.searchProjects({
query: input.q,
gameVersion: input.gameVersion,
softwareFamily: input.softwareFamily,
limit: input.limit,
});
}
@Get('projects/:projectId/versions')
@ApiOperation({ summary: 'List Modrinth project versions' })
async versions(
@Param('projectId') projectId: string,
@Query('serverId') serverId?: string,
@Query('gameVersion') gameVersion?: string,
@Query('softwareFamily') softwareFamily?: string,
) {
let resolvedGameVersion = gameVersion;
let resolvedSoftwareFamily = softwareFamily;
if (serverId) {
const server = await this.prisma.gameServer.findUniqueOrThrow({
where: { id: serverId },
});
resolvedGameVersion = server.minecraftVersion;
resolvedSoftwareFamily = server.softwareFamily;
}
if (!resolvedGameVersion || !resolvedSoftwareFamily) {
throw new BadRequestException('gameVersion and softwareFamily are required');
}
return this.catalogService.listProjectVersions(
projectId,
resolvedGameVersion,
resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT',
);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module';
import { CatalogController } from './catalog.controller';
import { CatalogService } from './catalog.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [CatalogController],
providers: [CatalogService],
exports: [CatalogService],
})
export class CatalogModule {}

View File

@@ -0,0 +1,81 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type {
CatalogSearchResponse,
CatalogVersionsResponse,
} from '@hexahost/contracts';
import {
getModrinthProjectVersions,
isVersionCompatible,
resolveModrinthLoader,
resolveProjectType,
searchModrinthProjects,
type SoftwareFamily,
} from '@hexahost/catalog';
@Injectable()
export class CatalogService {
async searchProjects(input: {
query: string;
gameVersion: string;
softwareFamily: SoftwareFamily;
limit?: number;
}): Promise<CatalogSearchResponse> {
const projectType = resolveProjectType(input.softwareFamily);
const loader = resolveModrinthLoader(input.softwareFamily);
if (!projectType || !loader) {
throw new BadRequestException(
'This server software does not support catalog add-ons',
);
}
const result = await searchModrinthProjects({
query: input.query,
projectType,
gameVersion: input.gameVersion,
loader,
limit: input.limit,
});
return {
projects: result.hits.map((project) => ({
id: project.project_id,
slug: project.slug,
name: project.title,
description: project.description,
projectType: project.project_type,
downloads: project.downloads,
iconUrl: project.icon_url ?? null,
})),
};
}
async listProjectVersions(
projectId: string,
gameVersion: string,
softwareFamily: SoftwareFamily,
): Promise<CatalogVersionsResponse> {
const loader = resolveModrinthLoader(softwareFamily);
if (!loader) {
throw new BadRequestException(
'This server software does not support catalog add-ons',
);
}
const versions = await getModrinthProjectVersions(projectId);
return {
versions: versions.map((version) => ({
id: version.id,
projectId: version.project_id,
name: version.name,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
compatible: isVersionCompatible(version, gameVersion, loader),
publishedAt: version.date_published,
})),
};
}
}

View File

@@ -327,7 +327,9 @@ export class NodesService {
normalized === 'world.archive' ||
normalized === 'world.replace' ||
normalized === 'storage.upload' ||
normalized === 'storage.download'
normalized === 'storage.download' ||
normalized === 'addon.install' ||
normalized === 'addon.remove'
);
}

View File

@@ -0,0 +1,56 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import type { AddonListResponse, InstallAddonResponse } from '@hexahost/contracts';
import { installAddonRequestSchema } from '@hexahost/contracts';
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
import { SessionGuard } from '../../auth/guards/session.guard';
import { AddonsService } from './addons.service';
@ApiTags('servers')
@Controller('servers/:serverId/addons')
@UseGuards(SessionGuard)
export class AddonsController {
constructor(private readonly addonsService: AddonsService) {}
@Get()
@ApiOperation({ summary: 'List installed add-ons' })
list(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
): Promise<AddonListResponse> {
return this.addonsService.listAddons(user.id, serverId);
}
@Post()
@ApiOperation({ summary: 'Install an add-on from the catalog' })
install(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Body() body: unknown,
) {
const input = installAddonRequestSchema.parse(body);
return this.addonsService.installAddon(user.id, serverId, input);
}
@Delete(':addonId')
@ApiOperation({ summary: 'Remove an installed add-on' })
remove(
@CurrentUser() user: { id: string },
@Param('serverId', ParseUUIDPipe) serverId: string,
@Param('addonId', ParseUUIDPipe) addonId: string,
): Promise<InstallAddonResponse> {
return this.addonsService.removeAddon(user.id, serverId, addonId);
}
}

View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { Queue } from 'bullmq';
import { getConfig } from '@hexahost/config';
import { AuthModule } from '../../auth/auth.module';
import { PrismaModule } from '../../prisma/prisma.module';
import { ServerStateService } from '../server-state.service';
import { AddonsController } from './addons.controller';
import { AddonsService, SERVER_ADDONS_QUEUE } from './addons.service';
@Module({
imports: [AuthModule, PrismaModule],
controllers: [AddonsController],
providers: [
AddonsService,
ServerStateService,
{
provide: SERVER_ADDONS_QUEUE,
useFactory: () => {
const config = getConfig();
return new Queue('server-addons', {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
},
},
],
exports: [AddonsService],
})
export class AddonsModule {}

View File

@@ -0,0 +1,229 @@
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
} from '@nestjs/common';
import { Queue } from 'bullmq';
import type {
AddonListResponse,
InstallAddonRequest,
InstallAddonResponse,
InstalledAddon,
} from '@hexahost/contracts';
import type { InstalledAddon as InstalledAddonRecord } from '@hexahost/database';
import {
getModrinthProject,
getModrinthVersion,
isVersionCompatible,
pickPrimaryFile,
resolveInstallDirectory,
resolveModrinthLoader,
resolveProjectType,
} from '@hexahost/catalog';
import { AuditService } from '../../auth/audit.service';
import { PrismaService } from '../../prisma/prisma.service';
import { findOwnedServer } from '../shared/server-ownership.util';
import { ServerStateService } from '../server-state.service';
export const SERVER_ADDONS_QUEUE = Symbol('SERVER_ADDONS_QUEUE');
const STOPPED_ONLY = new Set(['STOPPED']);
@Injectable()
export class AddonsService {
constructor(
private readonly prisma: PrismaService,
private readonly serverState: ServerStateService,
private readonly auditService: AuditService,
@Inject(SERVER_ADDONS_QUEUE) private readonly addonsQueue: Queue,
) {}
async listAddons(userId: string, serverId: string): Promise<AddonListResponse> {
await findOwnedServer(this.prisma, userId, serverId);
const addons = await this.prisma.installedAddon.findMany({
where: {
serverId,
status: { not: 'REMOVED' },
},
orderBy: { createdAt: 'desc' },
});
return { addons: addons.map((addon) => this.toResponse(addon)) };
}
async installAddon(
userId: string,
serverId: string,
input: InstallAddonRequest,
): Promise<InstallAddonResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (!server.nodeId) {
throw new BadRequestException('Server is not assigned to a node');
}
if (!STOPPED_ONLY.has(server.status)) {
throw new BadRequestException(
'Addons can only be installed while the server is STOPPED',
);
}
const projectType = resolveProjectType(server.softwareFamily);
const loader = resolveModrinthLoader(server.softwareFamily);
if (!projectType || !loader) {
throw new BadRequestException(
'This server software does not support add-on installation',
);
}
const version = await getModrinthVersion(input.versionId);
if (version.project_id !== input.projectId) {
throw new BadRequestException('Version does not belong to the selected project');
}
if (!isVersionCompatible(version, server.minecraftVersion, loader)) {
throw new BadRequestException(
'This version is not compatible with your server version and loader',
);
}
const file = pickPrimaryFile(version);
if (!file) {
throw new BadRequestException('No installable file found for this version');
}
const project = await getModrinthProject(input.projectId);
const projectName = project.title;
const projectSlug = project.slug;
const installDir = resolveInstallDirectory(projectType);
const filePath = `${installDir}/${file.filename}`;
const existing = await this.prisma.installedAddon.findFirst({
where: {
serverId,
projectId: input.projectId,
status: { in: ['PENDING', 'INSTALLING', 'INSTALLED'] },
},
});
if (existing) {
throw new ConflictException('This project is already installed on the server');
}
const addonType =
projectType === 'plugin'
? 'PLUGIN'
: projectType === 'mod'
? 'MOD'
: projectType === 'modpack'
? 'MODPACK'
: 'DATAPACK';
const addon = await this.prisma.installedAddon.create({
data: {
serverId,
projectId: input.projectId,
projectSlug,
projectName,
versionId: input.versionId,
versionNumber: version.version_number,
addonType,
fileName: file.filename,
filePath,
fileSha512: file.hashes['sha512'] ?? null,
status: 'PENDING',
},
});
const job = await this.addonsQueue.add('install-addon', {
addonId: addon.id,
serverId,
});
await this.auditService.record({
action: 'server.addon.install',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: {
addonId: addon.id,
projectId: input.projectId,
versionId: input.versionId,
},
});
return {
addon: this.toResponse(addon),
jobId: job.id?.toString(),
};
}
async removeAddon(
userId: string,
serverId: string,
addonId: string,
): Promise<InstallAddonResponse> {
const server = await findOwnedServer(this.prisma, userId, serverId);
this.serverState.assertNotInProgress(server.status);
if (!STOPPED_ONLY.has(server.status)) {
throw new BadRequestException(
'Addons can only be removed while the server is STOPPED',
);
}
const addon = await this.prisma.installedAddon.findFirstOrThrow({
where: { id: addonId, serverId },
});
await this.prisma.installedAddon.update({
where: { id: addonId },
data: { status: 'REMOVING' },
});
const job = await this.addonsQueue.add('remove-addon', {
addonId,
serverId,
});
await this.auditService.record({
action: 'server.addon.remove',
userId,
entityType: 'game_server',
entityId: serverId,
metadata: { addonId },
});
return {
addon: this.toResponse({ ...addon, status: 'REMOVING' }),
jobId: job.id?.toString(),
};
}
private toResponse(addon: InstalledAddonRecord): InstalledAddon {
return {
id: addon.id,
serverId: addon.serverId,
provider: addon.provider,
projectId: addon.projectId,
projectSlug: addon.projectSlug,
projectName: addon.projectName,
versionId: addon.versionId,
versionNumber: addon.versionNumber,
addonType: addon.addonType as InstalledAddon['addonType'],
fileName: addon.fileName,
filePath: addon.filePath,
status: addon.status as InstalledAddon['status'],
failureReason: addon.failureReason,
installedAt: addon.installedAt?.toISOString() ?? null,
createdAt: addon.createdAt.toISOString(),
};
}
}

View File

@@ -9,6 +9,7 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
import { AppConfigModule } from '../config/app-config.module';
import { ConsoleModule } from './console/console.module';
import { AddonsModule } from './addons/addons.module';
import { BackupsModule } from './backups/backups.module';
import { FilesModule } from './files/files.module';
import { WorldsModule } from './worlds/worlds.module';
@@ -30,6 +31,7 @@ import { PlansService } from './plans.service';
AppConfigModule,
AuthModule,
forwardRef(() => ConsoleModule),
forwardRef(() => AddonsModule),
forwardRef(() => BackupsModule),
forwardRef(() => WorldsModule),
forwardRef(() => FilesModule),

View File

@@ -0,0 +1,42 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('phase 5 api contract', () => {
it('documents catalog and add-ons routes', () => {
const files = [
'catalog/catalog.controller.ts',
'servers/addons/addons.controller.ts',
'servers/addons/addons.service.ts',
];
for (const file of files) {
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
assert.ok(source.length > 0, `${file} should exist`);
}
const catalogSource = readFileSync(
join(process.cwd(), 'src', 'catalog/catalog.controller.ts'),
'utf8',
);
assert.ok(catalogSource.includes("@Get('projects')"));
assert.ok(catalogSource.includes('projects/:projectId/versions'));
const addonsSource = readFileSync(
join(process.cwd(), 'src', 'servers/addons/addons.controller.ts'),
'utf8',
);
assert.ok(addonsSource.includes('servers/:serverId/addons'));
assert.ok(addonsSource.includes('@Post()'));
assert.ok(addonsSource.includes("@Delete(':addonId')"));
const serviceSource = readFileSync(
join(process.cwd(), 'src', 'servers/addons/addons.service.ts'),
'utf8',
);
assert.ok(serviceSource.includes('installAddon'));
assert.ok(serviceSource.includes('removeAddon'));
assert.ok(serviceSource.includes('AddonListResponse'));
});
});

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": "./"
"baseUrl": "./",
"incremental": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]

File diff suppressed because one or more lines are too long