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

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

View File

@@ -0,0 +1 @@
{"hash":"115c734a9a85e4b8","duration":1265,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}

BIN
.turbo/cache/115c734a9a85e4b8.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"3adaaf4e70c65a9b","duration":10643,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"ccc3808b3c87ac204bbc3347757fbf46de82003566c8663091ced17450a9da54"}

BIN
.turbo/cache/3adaaf4e70c65a9b.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"3bfd370d7035ee42","duration":1928,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}

BIN
.turbo/cache/3bfd370d7035ee42.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"3f1b3202485dbf64","duration":4880,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"ccc3808b3c87ac204bbc3347757fbf46de82003566c8663091ced17450a9da54"}

BIN
.turbo/cache/3f1b3202485dbf64.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"471f245a242db3dc","duration":2980,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}

BIN
.turbo/cache/471f245a242db3dc.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/catalog/dist/compatibility.test.d.ts.map":{"size":130,"mtime_nanos":1782470266408191300,"mode":420,"is_dir":false},"packages/catalog/.turbo/turbo-build.log":{"size":107,"mtime_nanos":1782470266511030600,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.js":{"size":1955,"mtime_nanos":1782470266376086200,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.d.ts.map":{"size":1722,"mtime_nanos":1782470266362014300,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.test.js.map":{"size":1968,"mtime_nanos":1782470266404180100,"mode":420,"is_dir":false},"packages/catalog/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/catalog/dist/index.d.ts":{"size":444,"mtime_nanos":1782470266422648100,"mode":420,"is_dir":false},"packages/catalog/dist/index.js.map":{"size":343,"mtime_nanos":1782470266419299000,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.js.map":{"size":1972,"mtime_nanos":1782470266342638500,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.js.map":{"size":1649,"mtime_nanos":1782470266374304200,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.test.js":{"size":2546,"mtime_nanos":1782470266406175000,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.d.ts":{"size":645,"mtime_nanos":1782470266385958300,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.test.d.ts":{"size":59,"mtime_nanos":1782470266409181500,"mode":420,"is_dir":false},"packages/catalog/dist/index.js":{"size":1772,"mtime_nanos":1782470266420191800,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.d.ts":{"size":1701,"mtime_nanos":1782470266365023500,"mode":420,"is_dir":false},"packages/catalog/dist/index.d.ts.map":{"size":383,"mtime_nanos":1782470266422289400,"mode":420,"is_dir":false},"packages/catalog/dist/compatibility.d.ts.map":{"size":570,"mtime_nanos":1782470266385164500,"mode":420,"is_dir":false},"packages/catalog/dist/modrinth.js":{"size":2292,"mtime_nanos":1782470266344300200,"mode":420,"is_dir":false}},"order":["packages/catalog/.turbo/turbo-build.log","packages/catalog/dist","packages/catalog/dist/compatibility.d.ts","packages/catalog/dist/compatibility.d.ts.map","packages/catalog/dist/compatibility.js","packages/catalog/dist/compatibility.js.map","packages/catalog/dist/compatibility.test.d.ts","packages/catalog/dist/compatibility.test.d.ts.map","packages/catalog/dist/compatibility.test.js","packages/catalog/dist/compatibility.test.js.map","packages/catalog/dist/index.d.ts","packages/catalog/dist/index.d.ts.map","packages/catalog/dist/index.js","packages/catalog/dist/index.js.map","packages/catalog/dist/modrinth.d.ts","packages/catalog/dist/modrinth.d.ts.map","packages/catalog/dist/modrinth.js","packages/catalog/dist/modrinth.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"5aa5da011660c2c9","duration":3151,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}

BIN
.turbo/cache/5aa5da011660c2c9.tar.zst vendored Normal file

Binary file not shown.

View File

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

View File

@@ -0,0 +1 @@
{"hash":"7a132a51c00654b1","duration":9687,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"87eb3cd8cd7934b9c17d7dbfccea73bbc10df425becef0ce1b07d1250f19ec30"}

BIN
.turbo/cache/7a132a51c00654b1.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":552,"mtime_nanos":1782470296888709000,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782470271479266800,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":556,"mtime_nanos":1782470271486156700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782470271480812300,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":755,"mtime_nanos":1782470271488872300,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"7f7f9b46d2a471cc","duration":6572,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}

BIN
.turbo/cache/7f7f9b46d2a471cc.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"852f7ffa109cd04f","duration":33558,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"ccc3808b3c87ac204bbc3347757fbf46de82003566c8663091ced17450a9da54"}

BIN
.turbo/cache/852f7ffa109cd04f.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"d0658ee7899696b6","duration":10968,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"83e0290356a6f639c530ca4978dc50a4755a8574a497f3865ec069d2420d336c"}

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

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"d124454312a82327","duration":6130,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}

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

Binary file not shown.

View File

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

View File

@@ -0,0 +1 @@
{"hash":"d69b680c10694eca","duration":3789,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}

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

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782470271643748200,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":556,"mtime_nanos":1782470271486156700,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782470271480812300,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts":{"size":755,"mtime_nanos":1782470271488872300,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782470271479266800,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"ebec3f203693ffe7","duration":8273,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"94894e54acd9a24107e97e0e497e6679c6fc40bb88282b3b018a69a306ccb75a"}

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

Binary file not shown.

View File

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

View File

@@ -0,0 +1 @@
{"hash":"f2025934be2abb0b","duration":1406,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}

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

Binary file not shown.

View File

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

View File

@@ -0,0 +1 @@
{"hash":"f241804a79446853","duration":3739,"sha":"4262464cd5c3c60e537ec8c04af22b23e879a223","dirty_hash":"0cb2bef10eb6f7a17cb9a697a422500b8a1225f367a4903bb7e21848f032996a"}

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

Binary file not shown.

View File

@@ -92,7 +92,7 @@ make build
## Aktueller Stand ## Aktueller Stand
**Phase 4** (Welten & Backups) — abgeschlossen. **Phase 5** (Software & Add-ons) — abgeschlossen.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details. Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
@@ -107,6 +107,7 @@ Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Detail
| Spieler | Online-Spieler, Whitelist | | Spieler | Online-Spieler, Whitelist |
| **Welten** | Welten anzeigen, Upload/Download (Server gestoppt) | | **Welten** | Welten anzeigen, Upload/Download (Server gestoppt) |
| **Backups** | Manuell/geplant, Restore, Retention | | **Backups** | Manuell/geplant, Restore, Retention |
| **Add-ons** | Modrinth-Suche, Paper-Plugins / Fabric-Mods installieren |
### Minecraft-Server lokal testen ### Minecraft-Server lokal testen

View File

@@ -9,13 +9,14 @@
"start:prod": "node dist/main.js", "start:prod": "node dist/main.js",
"lint": "node -e \"process.exit(0)\"", "lint": "node -e \"process.exit(0)\"",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "tsc && node --test test/**/*.test.js" "test": "tsc -p tsconfig.build.json && node --test test/**/*.test.js"
}, },
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/static": "^8.0.4", "@fastify/static": "^8.0.4",
"@fastify/websocket": "^11.0.2", "@fastify/websocket": "^11.0.2",
"@hexahost/auth": "workspace:*", "@hexahost/auth": "workspace:*",
"@hexahost/catalog": "workspace:*",
"@hexahost/config": "workspace:*", "@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*", "@hexahost/contracts": "workspace:*",
"@hexahost/database": "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 { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CatalogModule } from './catalog/catalog.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module'; import { NodesModule } from './nodes/nodes.module';
import { ServersModule } from './servers/servers.module'; import { ServersModule } from './servers/servers.module';
@@ -40,6 +41,7 @@ import { LoggerModule } from 'nestjs-pino';
AppConfigModule, AppConfigModule,
HealthModule, HealthModule,
AuthModule, AuthModule,
CatalogModule,
ServersModule, ServersModule,
NodesModule, NodesModule,
AdminModule, 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.archive' ||
normalized === 'world.replace' || normalized === 'world.replace' ||
normalized === 'storage.upload' || 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 { AppConfigModule } from '../config/app-config.module';
import { ConsoleModule } from './console/console.module'; import { ConsoleModule } from './console/console.module';
import { AddonsModule } from './addons/addons.module';
import { BackupsModule } from './backups/backups.module'; import { BackupsModule } from './backups/backups.module';
import { FilesModule } from './files/files.module'; import { FilesModule } from './files/files.module';
import { WorldsModule } from './worlds/worlds.module'; import { WorldsModule } from './worlds/worlds.module';
@@ -30,6 +31,7 @@ import { PlansService } from './plans.service';
AppConfigModule, AppConfigModule,
AuthModule, AuthModule,
forwardRef(() => ConsoleModule), forwardRef(() => ConsoleModule),
forwardRef(() => AddonsModule),
forwardRef(() => BackupsModule), forwardRef(() => BackupsModule),
forwardRef(() => WorldsModule), forwardRef(() => WorldsModule),
forwardRef(() => FilesModule), 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": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "rootDir": "./src",
"baseUrl": "./" "baseUrl": "./",
"incremental": false
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"] "exclude": ["node_modules", "dist", "test"]

File diff suppressed because one or more lines are too long

View File

@@ -189,6 +189,10 @@ func mapSoftwareFamily(family string) string {
switch strings.ToUpper(strings.TrimSpace(family)) { switch strings.ToUpper(strings.TrimSpace(family)) {
case "PAPER": case "PAPER":
return "PAPER" return "PAPER"
case "FABRIC":
return "FABRIC"
case "PURPUR":
return "PURPUR"
default: default:
return "VANILLA" return "VANILLA"
} }

View File

@@ -229,6 +229,30 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
return nil return nil
} }
// DeleteFile removes a file relative to dataRoot.
func DeleteFile(dataRoot, relativePath string) error {
filePath, err := ResolvePath(dataRoot, relativePath)
if err != nil {
return err
}
if err := rejectSymlinks(dataRoot, filePath); err != nil {
return err
}
info, err := os.Lstat(filePath)
if err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return err
}
if info.IsDir() {
return ErrIsDirectory
}
return os.Remove(filePath)
}
func rejectSymlinks(dataRoot, target string) error { func rejectSymlinks(dataRoot, target string) error {
root, err := filepath.Abs(filepath.Clean(dataRoot)) root, err := filepath.Abs(filepath.Clean(dataRoot))
if err != nil { if err != nil {

View File

@@ -44,6 +44,8 @@ const (
TypeServerWorldReplace MessageType = "server.world.replace" TypeServerWorldReplace MessageType = "server.world.replace"
TypeServerStorageUpload MessageType = "server.storage.upload" TypeServerStorageUpload MessageType = "server.storage.upload"
TypeServerStorageDownload MessageType = "server.storage.download" TypeServerStorageDownload MessageType = "server.storage.download"
TypeServerAddonInstall MessageType = "server.addon.install"
TypeServerAddonRemove MessageType = "server.addon.remove"
TypeServerMetricsSub MessageType = "server.metrics.subscribe" TypeServerMetricsSub MessageType = "server.metrics.subscribe"
TypeServerLogsSubscribe MessageType = "server.logs.subscribe" TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
) )
@@ -80,6 +82,8 @@ var controlToAgentTypes = map[MessageType]struct{}{
TypeServerWorldReplace: {}, TypeServerWorldReplace: {},
TypeServerStorageUpload: {}, TypeServerStorageUpload: {},
TypeServerStorageDownload: {}, TypeServerStorageDownload: {},
TypeServerAddonInstall: {},
TypeServerAddonRemove: {},
TypeServerMetricsSub: {}, TypeServerMetricsSub: {},
TypeServerLogsSubscribe: {}, TypeServerLogsSubscribe: {},
} }
@@ -220,6 +224,20 @@ type ServerWorldReplacePayload struct {
ArchivePath string `json:"archivePath"` ArchivePath string `json:"archivePath"`
} }
// ServerAddonInstallPayload downloads and installs an addon file.
type ServerAddonInstallPayload struct {
OperationMeta
DownloadURL string `json:"downloadUrl"`
RelativePath string `json:"relativePath"`
Sha512 string `json:"sha512,omitempty"`
}
// ServerAddonRemovePayload removes an installed addon file.
type ServerAddonRemovePayload struct {
OperationMeta
RelativePath string `json:"relativePath"`
}
// FileEntry describes a file or directory in a list response. // FileEntry describes a file or directory in a list response.
type FileEntry struct { type FileEntry struct {
Name string `json:"name"` Name string `json:"name"`

View File

@@ -570,6 +570,66 @@ func (m *Manager) HandleWorldReplace(ctx context.Context, correlationID string,
}) })
} }
// HandleAddonInstall downloads and installs an addon JAR.
func (m *Manager) HandleAddonInstall(ctx context.Context, correlationID string, payload protocol.ServerAddonInstallPayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State == ServerStateRunning {
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_RUNNING", "server must be stopped before installing addons")
return
}
localPath, err := files.ResolvePath(rec.DataPath, payload.RelativePath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "addon.install", filesErrorCode(err), err.Error())
return
}
if err := transfer.DownloadFile(payload.DownloadURL, localPath); err != nil {
m.failOperation(ctx, correlationID, meta, "addon.install", "DOWNLOAD_FAILED", err.Error())
return
}
if err := transfer.VerifySha512File(localPath, payload.Sha512); err != nil {
_ = os.Remove(localPath)
m.failOperation(ctx, correlationID, meta, "addon.install", "HASH_MISMATCH", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "addon.install", map[string]string{
"path": payload.RelativePath,
})
}
// HandleAddonRemove deletes an installed addon file.
func (m *Manager) HandleAddonRemove(ctx context.Context, correlationID string, payload protocol.ServerAddonRemovePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State == ServerStateRunning {
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_RUNNING", "server must be stopped before removing addons")
return
}
if err := files.DeleteFile(rec.DataPath, payload.RelativePath); err != nil {
m.failOperation(ctx, correlationID, meta, "addon.remove", filesErrorCode(err), err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "addon.remove", map[string]string{
"path": payload.RelativePath,
})
}
func (m *Manager) getServer(serverID string) (*serverRecord, bool) { func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()

View File

@@ -0,0 +1,34 @@
package transfer
import (
"crypto/sha512"
"encoding/hex"
"fmt"
"io"
"os"
)
// VerifySha512File checks that a file matches the expected sha512 hash.
func VerifySha512File(path, expected string) error {
if expected == "" {
return nil
}
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
hasher := sha512.New()
if _, err := io.Copy(hasher, file); err != nil {
return err
}
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expected {
return fmt.Errorf("sha512 mismatch")
}
return nil
}

View File

@@ -370,6 +370,22 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
} }
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload) c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
case protocol.TypeServerAddonInstall:
var payload protocol.ServerAddonInstallPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.addon.install", "error", err)
return
}
c.runtime.HandleAddonInstall(ctx, env.MessageID, payload)
case protocol.TypeServerAddonRemove:
var payload protocol.ServerAddonRemovePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.addon.remove", "error", err)
return
}
c.runtime.HandleAddonRemove(ctx, env.MessageID, payload)
default: default:
c.log.Debug("ignored control message", "type", env.Type) c.log.Debug("ignored control message", "type", env.Type)
} }

View File

@@ -192,7 +192,8 @@
"properties": "Eigenschaften", "properties": "Eigenschaften",
"players": "Spieler", "players": "Spieler",
"worlds": "Welten", "worlds": "Welten",
"backups": "Backups" "backups": "Backups",
"addons": "Add-ons"
}, },
"console": { "console": {
"loading": "Wird geladen…", "loading": "Wird geladen…",
@@ -317,6 +318,23 @@
"created": "Erstellt", "created": "Erstellt",
"actions": "Aktionen", "actions": "Aktionen",
"empty": "Noch keine Backups vorhanden." "empty": "Noch keine Backups vorhanden."
},
"addons": {
"loading": "Wird geladen…",
"unsupported": "Add-ons werden für diese Server-Software noch nicht unterstützt. Verwenden Sie Paper oder Fabric.",
"stoppedHint": "Add-ons können nur installiert oder entfernt werden, wenn der Server gestoppt ist.",
"searchPlaceholder": "Modrinth durchsuchen…",
"search": "Suchen",
"searchError": "Katalogsuche fehlgeschlagen.",
"versionsError": "Versionen konnten nicht geladen werden.",
"installError": "Installation fehlgeschlagen.",
"removeError": "Entfernen fehlgeschlagen.",
"select": "Versionen",
"compatibleVersions": "Kompatible Versionen für {name}",
"install": "Installieren",
"remove": "Entfernen",
"installed": "Installiert",
"empty": "Noch keine Add-ons installiert."
} }
} }
} }

View File

@@ -192,7 +192,8 @@
"properties": "Properties", "properties": "Properties",
"players": "Players", "players": "Players",
"worlds": "Worlds", "worlds": "Worlds",
"backups": "Backups" "backups": "Backups",
"addons": "Add-ons"
}, },
"console": { "console": {
"loading": "Loading…", "loading": "Loading…",
@@ -317,6 +318,23 @@
"created": "Created", "created": "Created",
"actions": "Actions", "actions": "Actions",
"empty": "No backups yet." "empty": "No backups yet."
},
"addons": {
"loading": "Loading…",
"unsupported": "Add-ons are not supported for this server software yet. Use Paper or Fabric.",
"stoppedHint": "Add-ons can only be installed or removed while the server is stopped.",
"searchPlaceholder": "Search Modrinth…",
"search": "Search",
"searchError": "Catalog search failed.",
"versionsError": "Could not load versions.",
"installError": "Installation failed.",
"removeError": "Remove failed.",
"select": "Versions",
"compatibleVersions": "Compatible versions for {name}",
"install": "Install",
"remove": "Remove",
"installed": "Installed",
"empty": "No add-ons installed yet."
} }
} }
} }

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerAddons } from "@/components/servers/server-addons";
interface ServerAddonsPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerAddonsPage({ params }: ServerAddonsPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerAddons serverId={id} />;
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { Button } from "@hexahost/ui";
import {
installAddon,
listCatalogVersions,
listInstalledAddons,
removeAddon,
searchCatalog,
type CatalogProject,
type CatalogVersion,
} from "@/lib/api/addons";
import { getServer } from "@/lib/api/servers";
interface ServerAddonsProps {
serverId: string;
}
export function ServerAddons({ serverId }: ServerAddonsProps) {
const t = useTranslations("servers.addons");
const [query, setQuery] = useState("");
const [results, setResults] = useState<CatalogProject[]>([]);
const [selectedProject, setSelectedProject] = useState<CatalogProject | null>(null);
const [versions, setVersions] = useState<CatalogVersion[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const { data: server } = useQuery({
queryKey: ["server", serverId],
queryFn: () => getServer(serverId),
});
const refreshInstalled = useCallback(async () => {
const data = await listInstalledAddons(serverId);
return data.addons;
}, [serverId]);
const [installed, setInstalled] = useState<Awaited<ReturnType<typeof refreshInstalled>>>([]);
useEffect(() => {
void refreshInstalled().then(setInstalled).catch(() => undefined);
}, [refreshInstalled]);
const supportsAddons =
server &&
["PAPER", "PURPUR", "FABRIC", "FORGE", "NEOFORGE", "QUILT"].includes(
server.softwareFamily,
);
async function handleSearch(event: React.FormEvent) {
event.preventDefault();
if (!server || !supportsAddons || !query.trim()) {
return;
}
setBusy(true);
setError(null);
try {
const response = await searchCatalog({
q: query.trim(),
gameVersion: server.minecraftVersion,
softwareFamily: server.softwareFamily,
});
setResults(response.projects);
setSelectedProject(null);
setVersions([]);
} catch (err) {
setError(err instanceof Error ? err.message : t("searchError"));
} finally {
setBusy(false);
}
}
async function handleSelectProject(project: CatalogProject) {
setSelectedProject(project);
setBusy(true);
setError(null);
try {
const response = await listCatalogVersions(project.id, serverId);
setVersions(response.versions.filter((version) => version.compatible));
} catch (err) {
setError(err instanceof Error ? err.message : t("versionsError"));
} finally {
setBusy(false);
}
}
async function handleInstall(version: CatalogVersion) {
if (!selectedProject) {
return;
}
setBusy(true);
setError(null);
try {
await installAddon(serverId, selectedProject.id, version.id);
setInstalled(await refreshInstalled());
} catch (err) {
setError(err instanceof Error ? err.message : t("installError"));
} finally {
setBusy(false);
}
}
async function handleRemove(addonId: string) {
setBusy(true);
setError(null);
try {
await removeAddon(serverId, addonId);
setInstalled(await refreshInstalled());
} catch (err) {
setError(err instanceof Error ? err.message : t("removeError"));
} finally {
setBusy(false);
}
}
if (!server) {
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
}
if (!supportsAddons) {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("unsupported")}</p>;
}
if (server.status !== "STOPPED") {
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("stoppedHint")}</p>;
}
return (
<div className="space-y-6">
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{error}
</p>
) : null}
<form onSubmit={(event) => void handleSearch(event)} className="flex gap-2">
<input
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("searchPlaceholder")}
/>
<Button type="submit" disabled={busy}>
{t("search")}
</Button>
</form>
{results.length > 0 ? (
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{results.map((project) => (
<li key={project.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div>
<p className="font-medium text-sm">{project.name}</p>
<p className="text-xs text-zinc-500 line-clamp-2">{project.description}</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => void handleSelectProject(project)}
disabled={busy}
>
{t("select")}
</Button>
</li>
))}
</ul>
) : null}
{selectedProject && versions.length > 0 ? (
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("compatibleVersions", { name: selectedProject.name })}</h3>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{versions.map((version) => (
<li key={version.id} className="flex items-center justify-between px-4 py-3 text-sm">
<span>{version.versionNumber}</span>
<Button size="sm" onClick={() => void handleInstall(version)} disabled={busy}>
{t("install")}
</Button>
</li>
))}
</ul>
</div>
) : null}
<div>
<h3 className="mb-2 text-sm font-medium">{t("installed")}</h3>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{installed.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
) : (
installed.map((addon) => (
<li key={addon.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
<div>
<p className="font-medium">{addon.projectName}</p>
<p className="text-xs text-zinc-500">
{addon.versionNumber} · {addon.status}
</p>
</div>
{addon.status === "INSTALLED" ? (
<Button
size="sm"
variant="ghost"
onClick={() => void handleRemove(addon.id)}
disabled={busy}
>
{t("remove")}
</Button>
) : null}
</li>
))
)}
</ul>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ const tabs = [
{ key: "players", suffix: "/players" }, { key: "players", suffix: "/players" },
{ key: "worlds", suffix: "/worlds" }, { key: "worlds", suffix: "/worlds" },
{ key: "backups", suffix: "/backups" }, { key: "backups", suffix: "/backups" },
{ key: "addons", suffix: "/addons" },
] as const; ] as const;
export function ServerTabs({ serverId }: ServerTabsProps) { export function ServerTabs({ serverId }: ServerTabsProps) {

View File

@@ -0,0 +1,81 @@
import { apiFetch } from "./auth";
import type { SoftwareFamily } from "./servers";
export interface CatalogProject {
id: string;
slug: string;
name: string;
description: string;
projectType: string;
downloads: number;
iconUrl: string | null;
}
export interface CatalogVersion {
id: string;
projectId: string;
name: string;
versionNumber: string;
gameVersions: string[];
loaders: string[];
compatible: boolean;
publishedAt: string;
}
export interface InstalledAddon {
id: string;
projectId: string;
projectName: string;
versionNumber: string;
addonType: string;
fileName: string;
status: string;
failureReason: string | null;
}
export async function searchCatalog(input: {
q: string;
gameVersion: string;
softwareFamily: SoftwareFamily;
}): Promise<{ projects: CatalogProject[] }> {
const params = new URLSearchParams({
q: input.q,
gameVersion: input.gameVersion,
softwareFamily: input.softwareFamily,
});
return apiFetch(`/catalog/projects?${params.toString()}`);
}
export async function listCatalogVersions(
projectId: string,
serverId: string,
): Promise<{ versions: CatalogVersion[] }> {
const params = new URLSearchParams({ serverId });
return apiFetch(`/catalog/projects/${projectId}/versions?${params.toString()}`);
}
export async function listInstalledAddons(
serverId: string,
): Promise<{ addons: InstalledAddon[] }> {
return apiFetch(`/servers/${serverId}/addons`);
}
export async function installAddon(
serverId: string,
projectId: string,
versionId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/addons`, {
method: "POST",
body: JSON.stringify({ projectId, versionId }),
});
}
export async function removeAddon(
serverId: string,
addonId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/addons/${addonId}`, {
method: "DELETE",
});
}

View File

@@ -12,6 +12,7 @@
"test": "node --test test/**/*.test.js" "test": "node --test test/**/*.test.js"
}, },
"dependencies": { "dependencies": {
"@hexahost/catalog": "workspace:*",
"@hexahost/config": "workspace:*", "@hexahost/config": "workspace:*",
"@hexahost/database": "workspace:*", "@hexahost/database": "workspace:*",
"@hexahost/storage": "workspace:*", "@hexahost/storage": "workspace:*",

View File

@@ -0,0 +1,171 @@
import { Job } from 'bullmq';
import { z } from 'zod';
import {
getModrinthVersion,
isVersionCompatible,
pickPrimaryFile,
resolveModrinthLoader,
} from '@hexahost/catalog';
import { prisma } from '@hexahost/database';
import { sendAgentRequest } from '../agent-bridge';
import { logger } from '../logger';
const installAddonJobSchema = z.object({
addonId: z.string().uuid(),
serverId: z.string().uuid(),
});
const removeAddonJobSchema = z.object({
addonId: z.string().uuid(),
serverId: z.string().uuid(),
});
const ADDON_TIMEOUT_MS = 10 * 60 * 1000;
export async function processInstallAddonJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'install-addon') {
return { status: 'ignored' };
}
const { addonId, serverId } = installAddonJobSchema.parse(job.data);
const addon = await prisma.installedAddon.findUniqueOrThrow({
where: { id: addonId },
});
const server = await prisma.gameServer.findUniqueOrThrow({
where: { id: serverId },
});
if (!server.nodeId) {
throw new Error(`Server ${serverId} is not assigned to a node`);
}
await prisma.installedAddon.update({
where: { id: addonId },
data: { status: 'INSTALLING' },
});
try {
const version = await getModrinthVersion(addon.versionId);
const loader = resolveModrinthLoader(server.softwareFamily);
if (!loader || !isVersionCompatible(version, server.minecraftVersion, loader)) {
throw new Error('Selected version is not compatible with this server');
}
const file = pickPrimaryFile(version);
if (!file) {
throw new Error('No installable file found for this version');
}
const response = await sendAgentRequest(
server.nodeId,
'server.addon.install',
{
serverId,
generation: server.version,
downloadUrl: file.url,
relativePath: addon.filePath,
sha512: file.hashes['sha512'] ?? addon.fileSha512 ?? '',
},
ADDON_TIMEOUT_MS,
);
if (!response.success) {
throw new Error(response.errorMessage ?? 'Addon install failed');
}
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'INSTALLED',
installedAt: new Date(),
fileSha512: file.hashes['sha512'] ?? addon.fileSha512,
},
});
return { status: 'installed' };
} catch (error) {
const message = error instanceof Error ? error.message : 'Addon install failed';
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'FAILED',
failureReason: message,
},
});
throw error;
}
}
export async function processRemoveAddonJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'remove-addon') {
return { status: 'ignored' };
}
const { addonId, serverId } = removeAddonJobSchema.parse(job.data);
const addon = await prisma.installedAddon.findUniqueOrThrow({
where: { id: addonId },
});
const server = await prisma.gameServer.findUniqueOrThrow({
where: { id: serverId },
});
if (!server.nodeId) {
throw new Error(`Server ${serverId} is not assigned to a node`);
}
await prisma.installedAddon.update({
where: { id: addonId },
data: { status: 'REMOVING' },
});
try {
const response = await sendAgentRequest(
server.nodeId,
'server.addon.remove',
{
serverId,
generation: server.version,
relativePath: addon.filePath,
},
ADDON_TIMEOUT_MS,
);
if (!response.success) {
throw new Error(response.errorMessage ?? 'Addon remove failed');
}
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'REMOVED',
},
});
return { status: 'removed' };
} catch (error) {
const message = error instanceof Error ? error.message : 'Addon remove failed';
await prisma.installedAddon.update({
where: { id: addonId },
data: {
status: 'FAILED',
failureReason: message,
},
});
throw error;
}
}
export async function processAddonJob(job: Job): Promise<{ status: string }> {
switch (job.name) {
case 'install-addon':
return processInstallAddonJob(job);
case 'remove-addon':
return processRemoveAddonJob(job);
default:
logger.warn({ jobName: job.name }, 'Unknown addon job');
return { status: 'ignored' };
}
}

View File

@@ -4,6 +4,7 @@ import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database'; import { prisma } from '@hexahost/database';
import { processNotificationJob } from './handlers/notifications'; import { processNotificationJob } from './handlers/notifications';
import { processAddonJob } from './handlers/server-addons';
import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups'; import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups';
import { processLifecycleJob } from './handlers/server-lifecycle'; import { processLifecycleJob } from './handlers/server-lifecycle';
import { processProvisionServerJob } from './handlers/server-provisioning'; import { processProvisionServerJob } from './handlers/server-provisioning';
@@ -12,6 +13,7 @@ import { logger } from './logger';
import { closeRedisConnections } from './redis'; import { closeRedisConnections } from './redis';
import { import {
QUEUE_NOTIFICATIONS, QUEUE_NOTIFICATIONS,
QUEUE_SERVER_ADDONS,
QUEUE_SERVER_BACKUPS, QUEUE_SERVER_BACKUPS,
QUEUE_SERVER_LIFECYCLE, QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING, QUEUE_SERVER_PROVISIONING,
@@ -43,6 +45,10 @@ function createQueueWorker(
return processBackupJob(job); return processBackupJob(job);
} }
if (queueName === QUEUE_SERVER_ADDONS) {
return processAddonJob(job);
}
if (queueName === QUEUE_SERVER_LIFECYCLE) { if (queueName === QUEUE_SERVER_LIFECYCLE) {
return processLifecycleJob(job); return processLifecycleJob(job);
} }

View File

@@ -1,3 +1,4 @@
export const QUEUE_SERVER_ADDONS = 'server-addons' as const;
export const QUEUE_SERVER_BACKUPS = 'server-backups' as const; export const QUEUE_SERVER_BACKUPS = 'server-backups' as const;
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const; export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const; export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
@@ -8,6 +9,7 @@ import { Queue } from 'bullmq';
import { validateConfig } from '@hexahost/config'; import { validateConfig } from '@hexahost/config';
export const WORKER_QUEUES = [ export const WORKER_QUEUES = [
QUEUE_SERVER_ADDONS,
QUEUE_SERVER_BACKUPS, QUEUE_SERVER_BACKUPS,
QUEUE_SERVER_LIFECYCLE, QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING, QUEUE_SERVER_PROVISIONING,
@@ -16,6 +18,22 @@ export const WORKER_QUEUES = [
export type WorkerQueueName = (typeof WORKER_QUEUES)[number]; export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
let addonsQueue: Queue | null = null;
export function getAddonsQueue(): Queue {
if (!addonsQueue) {
const config = validateConfig();
addonsQueue = new Queue(QUEUE_SERVER_ADDONS, {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
}
return addonsQueue;
}
let backupsQueue: Queue | null = null; let backupsQueue: Queue | null = null;
export function getBackupsQueue(): Queue { export function getBackupsQueue(): Queue {

File diff suppressed because one or more lines are too long

View File

@@ -2,68 +2,59 @@
Last updated: 2026-06-26 Last updated: 2026-06-26
## Current phase: Phase 4Worlds & backups ✅ ## Current phase: Phase 5Software & add-ons ✅
### Phase 4 completed ### Phase 5 completed
| Area | Status | Notes | | Area | Status | Notes |
|------|--------|-------| |------|--------|-------|
| Prisma backup models | Done | ServerBackup, BackupRestore, BackupSchedule, WorldUpload | | Prisma InstalledAddon | Done | Plugin/mod install tracking |
| `@hexahost/storage` | Done | S3/MinIO presigned URLs, object keys | | `@hexahost/catalog` | Done | Modrinth API + compatibility checks |
| `@hexahost/contracts` phase 4 | Done | backups, worlds schemas | | `@hexahost/contracts` phase 5 | Done | catalog, addons schemas |
| Go agent v0.4 | Done | Archive, validate, RCON prepare/release, S3 transfer | | Go agent v0.5 | Done | addon.install/remove with SHA512 verify |
| Worker backup queue | Done | create/restore/upload, retention, schedule tick | | Worker server-addons queue | Done | install-addon, remove-addon jobs |
| API BackupsModule | Done | CRUD, restore, schedule, download URL | | API CatalogModule | Done | Modrinth search + versions |
| API WorldsModule | Done | World info, upload, download via backup | | API AddonsModule | Done | Install/remove with audit |
| Web worlds/backups tabs | Done | Upload, list, backup management | | Web add-ons tab | Done | Search, install, list, remove |
| State machine | Done | BACKING_UP, RESTORING | | Docker FABRIC/PURPUR | Done | Extended TYPE mapping for itzg image |
### Abnahmekriterium Phase 4 ### Abnahmekriterium Phase 5
- [x] World backup to S3 (MinIO in dev) - [x] Modrinth catalog search with loader + game version facets
- [x] World download (presigned URL) - [x] Compatibility check before install
- [x] World upload and replace (server stopped) - [x] Paper plugin → `plugins/` directory
- [x] Backup restore with safety backup - [x] Fabric mod → `mods/` directory
- [x] Retention enforcement - [x] SHA512 verification on agent
- [x] Scheduled backups (interval-based) - [x] Install/remove only when server STOPPED
- [ ] Manual E2E validation (requires Docker + agent + MinIO) - [ ] Manual E2E (Paper + Fabric server with agent)
### Local vertical slice ### Local test
```bash 1. Create **Paper** server → stop → **Add-ons** → search plugin (e.g. LuckPerms) → install
pnpm dev:infra # includes MinIO on :9000 2. Create **Fabric** server → stop → install compatible mod
pnpm db:migrate 3. Start server and verify JAR in container
pnpm db:seed
pnpm dev
# Node Agent (Go + Docker)
cd apps/node-agent && make run
```
1. Stop server → **Welten**`.tar.gz` hochladen
2. **Backups** → manuelles Backup erstellen → Download
3. Backup wiederherstellen (Server gestoppt)
4. Geplante Backups aktivieren
### Known limitations ### Known limitations
- Upload expects gzip-compressed tar (`.tar.gz`) with world folder or `level.dat` at root. - Modpacks/datapacks UI deferred
- Restore/download require agent on node; worker orchestrates S3 transfer. - No automatic update/rollback yet (metadata stored for future)
- Scheduled backups use simple `intervalHours` (no cron expressions yet). - CurseForge not integrated
- No server-side encryption of backups beyond MinIO/S3 transport (deferred). - Server must be reprovisioned to change software family (existing flow)
---
## Phase 4 — Worlds & backups ✅
S3 backups, world upload/download, restore, retention, schedules.
--- ---
## Phase 3 — Console, files, settings ✅ ## Phase 3 — Console, files, settings ✅
Live console, file manager, `server.properties`, player lists, audit.
--- ---
## Phase 2 — Single-Node Vertical Slice ✅ ## Phase 2 — Single-Node Vertical Slice ✅
Provisioning, start/stop, node agent, web panel.
--- ---
## Phase 1 — Authentication ✅ ## Phase 1 — Authentication ✅
@@ -72,6 +63,6 @@ Provisioning, start/stop, node agent, web panel.
## Phase 0 — Monorepo foundation ✅ ## Phase 0 — Monorepo foundation ✅
### Next phase: Phase 5Software & add-ons ### Next phase: Phase 6Multi-Node and queue
- Software catalog, Modrinth, mods/plugins/modpacks - Scheduler, resource reservation, start queue, drain

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/catalog",
"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,70 @@
import { describe, expect, it } from 'vitest';
import {
isVersionCompatible,
pickPrimaryFile,
resolveInstallDirectory,
resolveModrinthLoader,
resolveProjectType,
} from './compatibility';
import type { ModrinthVersion } from './modrinth';
describe('catalog compatibility', () => {
it('maps paper to plugin installs', () => {
expect(resolveModrinthLoader('PAPER')).toBe('paper');
expect(resolveProjectType('PAPER')).toBe('plugin');
expect(resolveInstallDirectory('plugin')).toBe('plugins');
});
it('maps fabric to mod installs', () => {
expect(resolveModrinthLoader('FABRIC')).toBe('fabric');
expect(resolveProjectType('FABRIC')).toBe('mod');
expect(resolveInstallDirectory('mod')).toBe('mods');
});
it('checks version compatibility', () => {
const version: ModrinthVersion = {
id: 'v1',
project_id: 'p1',
name: '1.0.0',
version_number: '1.0.0',
game_versions: ['1.21.1'],
loaders: ['paper'],
files: [],
date_published: '2026-01-01T00:00:00Z',
};
expect(isVersionCompatible(version, '1.21.1', 'paper')).toBe(true);
expect(isVersionCompatible(version, '1.20.1', 'paper')).toBe(false);
});
it('picks primary jar file', () => {
const version: ModrinthVersion = {
id: 'v1',
project_id: 'p1',
name: '1.0.0',
version_number: '1.0.0',
game_versions: ['1.21.1'],
loaders: ['fabric'],
files: [
{
filename: 'readme.txt',
url: 'https://example.com/readme.txt',
hashes: {},
primary: false,
size: 10,
},
{
filename: 'mod.jar',
url: 'https://example.com/mod.jar',
hashes: { sha512: 'abc' },
primary: true,
size: 100,
},
],
date_published: '2026-01-01T00:00:00Z',
};
expect(pickPrimaryFile(version)?.filename).toBe('mod.jar');
});
});

View File

@@ -0,0 +1,75 @@
import type { ModrinthProjectType, ModrinthVersion, SoftwareFamily } from './modrinth';
export function resolveModrinthLoader(
softwareFamily: SoftwareFamily,
): string | null {
switch (softwareFamily) {
case 'PAPER':
case 'PURPUR':
return 'paper';
case 'FABRIC':
return 'fabric';
case 'FORGE':
return 'forge';
case 'NEOFORGE':
return 'neoforge';
case 'QUILT':
return 'quilt';
default:
return null;
}
}
export function resolveProjectType(
softwareFamily: SoftwareFamily,
): ModrinthProjectType | null {
switch (softwareFamily) {
case 'PAPER':
case 'PURPUR':
return 'plugin';
case 'FABRIC':
case 'FORGE':
case 'NEOFORGE':
case 'QUILT':
return 'mod';
default:
return null;
}
}
export function resolveInstallDirectory(projectType: ModrinthProjectType): string {
switch (projectType) {
case 'plugin':
return 'plugins';
case 'mod':
return 'mods';
case 'modpack':
return 'modpacks';
default:
return 'datapacks';
}
}
export function isVersionCompatible(
version: ModrinthVersion,
gameVersion: string,
loader: string,
): boolean {
const gameMatch = version.game_versions.some(
(value) => value === gameVersion || gameVersion.startsWith(value),
);
const loaderMatch = version.loaders.includes(loader);
return gameMatch && loaderMatch;
}
export function pickPrimaryFile(version: ModrinthVersion): ModrinthVersion['files'][number] | null {
const jarFiles = version.files.filter((file) =>
file.filename.toLowerCase().endsWith('.jar'),
);
if (jarFiles.length === 0) {
return version.files[0] ?? null;
}
return jarFiles.find((file) => file.primary) ?? jarFiles[0] ?? null;
}

View File

@@ -0,0 +1,20 @@
export {
searchModrinthProjects,
getModrinthProject,
getModrinthProjectVersions,
getModrinthVersion,
type ModrinthProject,
type ModrinthSearchResponse,
type ModrinthVersion,
type ModrinthVersionFile,
type ModrinthProjectType,
type SoftwareFamily,
} from './modrinth';
export {
resolveModrinthLoader,
resolveProjectType,
resolveInstallDirectory,
isVersionCompatible,
pickPrimaryFile,
} from './compatibility';

View File

@@ -0,0 +1,148 @@
export type SoftwareFamily =
| 'VANILLA'
| 'PAPER'
| 'PURPUR'
| 'FABRIC'
| 'FORGE'
| 'NEOFORGE'
| 'QUILT'
| 'BEDROCK_DEDICATED';
export type ModrinthProjectType = 'mod' | 'plugin' | 'modpack' | 'resourcepack' | 'shader';
export interface ModrinthProject {
project_id: string;
slug: string;
title: string;
description: string;
project_type: ModrinthProjectType;
downloads: number;
icon_url?: string;
}
export interface ModrinthSearchResponse {
hits: ModrinthProject[];
offset: number;
limit: number;
total_hits: number;
}
export interface ModrinthVersionFile {
hashes: Record<string, string>;
url: string;
filename: string;
primary: boolean;
size: number;
}
export interface ModrinthVersion {
id: string;
project_id: string;
name: string;
version_number: string;
game_versions: string[];
loaders: string[];
files: ModrinthVersionFile[];
date_published: string;
}
const MODRINTH_API = 'https://api.modrinth.com/v2';
export async function searchModrinthProjects(input: {
query: string;
projectType: ModrinthProjectType;
gameVersion: string;
loader?: string;
limit?: number;
}): Promise<ModrinthSearchResponse> {
const facets: string[][] = [
[`project_type:${input.projectType}`],
[`versions:${input.gameVersion}`],
];
if (input.loader) {
facets.push([`categories:${input.loader}`]);
}
const params = new URLSearchParams({
query: input.query,
limit: String(input.limit ?? 20),
index: 'relevance',
facets: JSON.stringify(facets),
});
const response = await fetch(`${MODRINTH_API}/search?${params.toString()}`, {
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
});
if (!response.ok) {
throw new Error(`Modrinth search failed: ${response.status}`);
}
return response.json() as Promise<ModrinthSearchResponse>;
}
export async function getModrinthProjectVersions(
projectId: string,
): Promise<ModrinthVersion[]> {
const response = await fetch(
`${MODRINTH_API}/project/${encodeURIComponent(projectId)}/version`,
{
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
},
);
if (!response.ok) {
throw new Error(`Modrinth versions failed: ${response.status}`);
}
return response.json() as Promise<ModrinthVersion[]>;
}
export interface ModrinthProjectDetails {
id: string;
slug: string;
title: string;
description: string;
project_type: ModrinthProjectType;
downloads: number;
icon_url?: string;
}
export async function getModrinthProject(projectId: string): Promise<ModrinthProjectDetails> {
const response = await fetch(
`${MODRINTH_API}/project/${encodeURIComponent(projectId)}`,
{
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
},
);
if (!response.ok) {
throw new Error(`Modrinth project failed: ${response.status}`);
}
return response.json() as Promise<ModrinthProjectDetails>;
}
export async function getModrinthVersion(versionId: string): Promise<ModrinthVersion> {
const response = await fetch(
`${MODRINTH_API}/version/${encodeURIComponent(versionId)}`,
{
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
},
);
if (!response.ok) {
throw new Error(`Modrinth version failed: ${response.status}`);
}
return response.json() as Promise<ModrinthVersion>;
}

View File

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

View File

@@ -0,0 +1,49 @@
import { z } from 'zod';
export const addonTypeSchema = z.enum(['PLUGIN', 'MOD', 'MODPACK', 'DATAPACK']);
export const addonStatusSchema = z.enum([
'PENDING',
'INSTALLING',
'INSTALLED',
'FAILED',
'REMOVING',
'REMOVED',
]);
export const installedAddonSchema = z.object({
id: z.string().uuid(),
serverId: z.string().uuid(),
provider: z.string(),
projectId: z.string(),
projectSlug: z.string(),
projectName: z.string(),
versionId: z.string(),
versionNumber: z.string(),
addonType: addonTypeSchema,
fileName: z.string(),
filePath: z.string(),
status: addonStatusSchema,
failureReason: z.string().nullable(),
installedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
});
export const addonListResponseSchema = z.object({
addons: z.array(installedAddonSchema),
});
export const installAddonRequestSchema = z.object({
projectId: z.string().min(1),
versionId: z.string().min(1),
});
export const installAddonResponseSchema = z.object({
addon: installedAddonSchema,
jobId: z.string().optional(),
});
export type InstalledAddon = z.infer<typeof installedAddonSchema>;
export type AddonListResponse = z.infer<typeof addonListResponseSchema>;
export type InstallAddonRequest = z.infer<typeof installAddonRequestSchema>;
export type InstallAddonResponse = z.infer<typeof installAddonResponseSchema>;

View File

@@ -0,0 +1,50 @@
import { z } from 'zod';
export const catalogProjectSchema = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
description: z.string(),
projectType: z.enum(['mod', 'plugin', 'modpack', 'resourcepack', 'shader']),
downloads: z.number().int(),
iconUrl: z.string().url().nullable(),
});
export const catalogSearchQuerySchema = z.object({
q: z.string().min(1).max(128),
gameVersion: z.string().min(1).max(32),
softwareFamily: z.enum([
'PAPER',
'PURPUR',
'FABRIC',
'FORGE',
'NEOFORGE',
'QUILT',
]),
limit: z.coerce.number().int().min(1).max(50).optional(),
});
export const catalogSearchResponseSchema = z.object({
projects: z.array(catalogProjectSchema),
});
export const catalogVersionSchema = z.object({
id: z.string(),
projectId: z.string(),
name: z.string(),
versionNumber: z.string(),
gameVersions: z.array(z.string()),
loaders: z.array(z.string()),
compatible: z.boolean(),
publishedAt: z.string().datetime(),
});
export const catalogVersionsResponseSchema = z.object({
versions: z.array(catalogVersionSchema),
});
export type CatalogProject = z.infer<typeof catalogProjectSchema>;
export type CatalogSearchQuery = z.infer<typeof catalogSearchQuerySchema>;
export type CatalogSearchResponse = z.infer<typeof catalogSearchResponseSchema>;
export type CatalogVersion = z.infer<typeof catalogVersionSchema>;
export type CatalogVersionsResponse = z.infer<typeof catalogVersionsResponseSchema>;

View File

@@ -154,3 +154,29 @@ export {
type WorldUploadResponse, type WorldUploadResponse,
type WorldDownloadUrlResponse, type WorldDownloadUrlResponse,
} from './worlds'; } from './worlds';
export {
catalogProjectSchema,
catalogSearchQuerySchema,
catalogSearchResponseSchema,
catalogVersionSchema,
catalogVersionsResponseSchema,
type CatalogProject,
type CatalogSearchQuery,
type CatalogSearchResponse,
type CatalogVersion,
type CatalogVersionsResponse,
} from './catalog';
export {
addonTypeSchema,
addonStatusSchema,
installedAddonSchema,
addonListResponseSchema,
installAddonRequestSchema,
installAddonResponseSchema,
type InstalledAddon,
type AddonListResponse,
type InstallAddonRequest,
type InstallAddonResponse,
} from './addons';

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
-- Phase 5: Software & Add-ons
CREATE TYPE "AddonType" AS ENUM ('PLUGIN', 'MOD', 'MODPACK', 'DATAPACK');
CREATE TYPE "AddonInstallStatus" AS ENUM ('PENDING', 'INSTALLING', 'INSTALLED', 'FAILED', 'REMOVING', 'REMOVED');
CREATE TABLE "installed_addons" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'modrinth',
"projectId" TEXT NOT NULL,
"projectSlug" TEXT NOT NULL,
"projectName" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"versionNumber" TEXT NOT NULL,
"addonType" "AddonType" NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSha512" TEXT,
"status" "AddonInstallStatus" NOT NULL DEFAULT 'PENDING',
"failureReason" TEXT,
"installedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "installed_addons_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "installed_addons_serverId_idx" ON "installed_addons"("serverId");
CREATE INDEX "installed_addons_status_idx" ON "installed_addons"("status");
CREATE UNIQUE INDEX "installed_addons_serverId_projectId_versionId_key" ON "installed_addons"("serverId", "projectId", "versionId");
ALTER TABLE "installed_addons" ADD CONSTRAINT "installed_addons_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -96,6 +96,22 @@ enum WorldUploadStatus {
FAILED FAILED
} }
enum AddonType {
PLUGIN
MOD
MODPACK
DATAPACK
}
enum AddonInstallStatus {
PENDING
INSTALLING
INSTALLED
FAILED
REMOVING
REMOVED
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
@@ -265,6 +281,7 @@ model GameServer {
backupRestores BackupRestore[] backupRestores BackupRestore[]
backupSchedule BackupSchedule? backupSchedule BackupSchedule?
worldUploads WorldUpload[] worldUploads WorldUpload[]
installedAddons InstalledAddon[]
@@index([userId]) @@index([userId])
@@index([nodeId]) @@index([nodeId])
@@ -502,3 +519,30 @@ model WorldUpload {
@@index([status]) @@index([status])
@@map("world_uploads") @@map("world_uploads")
} }
model InstalledAddon {
id String @id @default(uuid())
serverId String
provider String @default("modrinth")
projectId String
projectSlug String
projectName String
versionId String
versionNumber String
addonType AddonType
fileName String
filePath String
fileSha512 String?
status AddonInstallStatus @default(PENDING)
failureReason String?
installedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, projectId, versionId])
@@index([serverId])
@@index([status])
@@map("installed_addons")
}

View File

@@ -16,6 +16,7 @@ export type {
BackupRestore, BackupRestore,
BackupSchedule, BackupSchedule,
WorldUpload, WorldUpload,
InstalledAddon,
GameNode, GameNode,
GameNodeHeartbeat, GameNodeHeartbeat,
Plan, Plan,

File diff suppressed because one or more lines are too long

21
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
'@hexahost/auth': '@hexahost/auth':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/auth version: link:../../packages/auth
'@hexahost/catalog':
specifier: workspace:*
version: link:../../packages/catalog
'@hexahost/config': '@hexahost/config':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/config version: link:../../packages/config
@@ -172,6 +175,9 @@ importers:
apps/worker: apps/worker:
dependencies: dependencies:
'@hexahost/catalog':
specifier: workspace:*
version: link:../../packages/catalog
'@hexahost/config': '@hexahost/config':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/config version: link:../../packages/config
@@ -235,6 +241,21 @@ importers:
specifier: ^3.0.8 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) version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
packages/catalog:
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/config: packages/config:
dependencies: dependencies:
zod: zod: