From 50cd4b3ffd046dab5ca0089bbb68ed8ead433598 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Sun, 5 Jul 2026 18:39:53 +0200 Subject: [PATCH] Enhance API with OIDC support, including login and callback endpoints. Update environment variables for OIDC configuration in .env.example. Add new features to the catalog service for listing software families, Minecraft versions, and deployment regions. Implement server management actions such as kill, delete, and update in the servers module. Integrate feature flags for maintenance mode in server operations. Update pnpm-lock.yaml with new dependencies and versions. --- .env.example | 10 + apps/api/package.json | 6 + apps/api/src/abuse/abuse.controller.ts | 29 + apps/api/src/abuse/abuse.module.ts | 15 + apps/api/src/abuse/abuse.service.ts | 78 + apps/api/src/admin/admin-abuse.controller.ts | 58 + apps/api/src/admin/admin-audit.controller.ts | 31 + apps/api/src/admin/admin-audit.service.ts | 62 + apps/api/src/admin/admin-auth.util.ts | 18 + .../src/admin/admin-dashboard.controller.ts | 22 + apps/api/src/admin/admin-dashboard.service.ts | 51 + apps/api/src/admin/admin-jobs.controller.ts | 31 + apps/api/src/admin/admin-jobs.service.ts | 47 + apps/api/src/admin/admin-users.controller.ts | 57 + apps/api/src/admin/admin-users.service.ts | 77 + apps/api/src/admin/admin.module.ts | 29 +- apps/api/src/app.module.ts | 27 +- apps/api/src/auth/auth.controller.ts | 32 + apps/api/src/auth/auth.module.ts | 9 +- apps/api/src/auth/auth.service.ts | 73 + apps/api/src/catalog/catalog.controller.ts | 75 + apps/api/src/catalog/catalog.service.ts | 23 + apps/api/src/common/csrf/csrf.controller.ts | 20 + apps/api/src/common/csrf/csrf.guard.ts | 19 + apps/api/src/common/csrf/csrf.module.ts | 15 + apps/api/src/common/csrf/csrf.service.ts | 77 + .../interceptors/idempotency.interceptor.ts | 71 + .../src/compliance/compliance.controller.ts | 63 + apps/api/src/compliance/compliance.module.ts | 16 + apps/api/src/compliance/compliance.service.ts | 400 ++++ .../feature-flags/feature-flags.controller.ts | 37 + .../src/feature-flags/feature-flags.module.ts | 25 + .../whmcs/whmcs-integration.controller.ts | 78 + .../whmcs/whmcs-integration.module.ts | 3 +- .../whmcs/whmcs-integration.service.ts | 152 ++ apps/api/src/main.ts | 2 + apps/api/src/nodes/nodes.service.ts | 3 + .../notifications/notifications.controller.ts | 61 + .../src/notifications/notifications.module.ts | 15 + .../notifications/notifications.service.ts | 166 ++ .../organizations/organizations.controller.ts | 69 + .../src/organizations/organizations.module.ts | 15 + .../organizations/organizations.service.ts | 217 ++ .../api/src/servers/files/files.controller.ts | 66 +- apps/api/src/servers/files/files.service.ts | 168 +- .../src/servers/members/members.controller.ts | 119 ++ .../api/src/servers/members/members.module.ts | 8 + .../src/servers/players/players.controller.ts | 112 +- .../src/servers/players/players.service.ts | 153 +- .../servers/schedules/schedules.controller.ts | 77 + .../src/servers/schedules/schedules.module.ts | 15 + .../servers/schedules/schedules.service.ts | 150 ++ apps/api/src/servers/server-state.service.ts | 91 +- apps/api/src/servers/servers.controller.ts | 38 +- apps/api/src/servers/servers.module.ts | 9 + apps/api/src/servers/servers.service.ts | 189 ++ .../servers/shared/server-ownership.util.ts | 56 +- .../servers/software/software.controller.ts | 40 + .../src/servers/software/software.module.ts | 12 + apps/api/src/support/support.controller.ts | 69 + apps/api/src/support/support.module.ts | 15 + apps/api/src/support/support.service.ts | 143 ++ apps/docs/README.md | 28 + apps/docs/index.html | 130 ++ apps/docs/package.json | 14 + apps/e2e/package.json | 18 + apps/e2e/playwright.config.ts | 22 + apps/e2e/tests/smoke.spec.ts | 52 + apps/e2e/tsconfig.json | 10 + apps/node-agent/internal/archive/archive.go | 93 + apps/node-agent/internal/docker/client.go | 18 +- apps/node-agent/internal/files/safe.go | 25 + apps/node-agent/internal/protocol/messages.go | 33 +- apps/node-agent/internal/runtime/manager.go | 85 + apps/node-agent/internal/ws/client.go | 24 + apps/web/messages/de.json | 552 ++++- apps/web/messages/en.json | 552 ++++- .../app/[locale]/(account)/billing/page.tsx | 13 + .../web/src/app/[locale]/(account)/layout.tsx | 24 + .../[locale]/(account)/notifications/page.tsx | 13 + .../app/[locale]/(account)/profile/page.tsx | 13 + .../web/src/app/[locale]/admin/audit/page.tsx | 13 + apps/web/src/app/[locale]/admin/jobs/page.tsx | 13 + apps/web/src/app/[locale]/admin/layout.tsx | 34 + .../web/src/app/[locale]/admin/nodes/page.tsx | 13 + apps/web/src/app/[locale]/admin/page.tsx | 13 + .../web/src/app/[locale]/admin/users/page.tsx | 13 + .../web/src/app/[locale]/dashboard/layout.tsx | 27 +- apps/web/src/app/[locale]/docs/page.tsx | 48 + apps/web/src/app/[locale]/features/page.tsx | 30 + apps/web/src/app/[locale]/impressum/page.tsx | 37 + apps/web/src/app/[locale]/pricing/page.tsx | 51 + apps/web/src/app/[locale]/privacy/page.tsx | 29 + apps/web/src/app/[locale]/security/page.tsx | 6 + .../app/[locale]/servers/[id]/access/page.tsx | 13 + .../[locale]/servers/[id]/schedules/page.tsx | 13 + .../[locale]/servers/[id]/software/page.tsx | 13 + apps/web/src/app/[locale]/servers/layout.tsx | 27 +- apps/web/src/app/[locale]/status/page.tsx | 38 + apps/web/src/app/[locale]/terms/page.tsx | 29 + .../components/account/billing-content.tsx | 91 + .../account/notifications-content.tsx | 217 ++ .../components/account/profile-content.tsx | 72 + .../components/admin/admin-audit-content.tsx | 92 + .../admin/admin-dashboard-content.tsx | 61 + .../components/admin/admin-jobs-content.tsx | 68 + .../components/admin/admin-nodes-content.tsx | 128 ++ .../components/admin/admin-users-content.tsx | 132 ++ .../dashboard/dashboard-user-info.tsx | 8 + .../web/src/components/layout/account-nav.tsx | 45 + apps/web/src/components/layout/admin-nav.tsx | 50 + apps/web/src/components/layout/footer.tsx | 29 +- apps/web/src/components/layout/header.tsx | 18 + .../components/layout/public-page-shell.tsx | 25 + .../components/providers/auth-provider.tsx | 2 + .../components/servers/create-server-form.tsx | 213 +- .../servers/create-server-wizard.tsx | 290 +++ .../src/components/servers/server-access.tsx | 249 +++ .../src/components/servers/server-files.tsx | 236 ++- .../src/components/servers/server-players.tsx | 366 +++- .../components/servers/server-schedules.tsx | 389 ++++ .../components/servers/server-software.tsx | 110 + .../src/components/servers/server-tabs.tsx | 3 + apps/web/src/lib/api/admin.ts | 143 ++ apps/web/src/lib/api/auth.ts | 41 + apps/web/src/lib/api/compliance.ts | 47 + apps/web/src/lib/api/files.ts | 84 +- apps/web/src/lib/api/members.ts | 52 + apps/web/src/lib/api/notifications.ts | 75 + apps/web/src/lib/api/players.ts | 116 +- apps/web/src/lib/api/schedules.ts | 78 + apps/web/src/lib/api/software.ts | 27 + apps/web/src/middleware.ts | 50 +- apps/worker/package.json | 4 + apps/worker/src/config.ts | 8 +- .../src/handlers/notification-dispatch.ts | 106 + apps/worker/src/handlers/notifications.ts | 62 +- .../src/handlers/schedules-compliance.ts | 392 ++++ apps/worker/src/handlers/server-lifecycle.ts | 23 + apps/worker/src/index.ts | 29 + apps/worker/src/queues.ts | 20 + deploy/compose/compose.prod.yml | 242 +++ deploy/monitoring/grafana/datasources.yml | 38 + deploy/monitoring/loki/loki-config.yml | 56 + deploy/traefik/dynamic/gamecloud.yml | 95 + deploy/traefik/traefik.yml | 59 + docs/IMPLEMENTATION_STATUS.md | 28 +- docs/MASTERPROMPT_GAP_ANALYSIS.md | 86 + docs/api/README.md | 51 + docs/integrations/whmcs/addons.md | 56 + .../whmcs/configurable-options.md | 64 + docs/integrations/whmcs/configuration.md | 77 + docs/integrations/whmcs/installation.md | 93 + docs/integrations/whmcs/products.md | 71 + docs/integrations/whmcs/security.md | 79 + docs/integrations/whmcs/troubleshooting.md | 101 + docs/integrations/whmcs/upgrades.md | 74 + docs/operations/backup-restore.md | 108 + docs/operations/disaster-recovery.md | 80 + docs/operations/incident-response.md | 84 + docs/operations/node-drain.md | 101 + docs/operations/object-storage.md | 90 + docs/operations/server-migration.md | 79 + docs/operations/traefik.md | 96 + docs/operations/upgrades.md | 86 + docs/security/backup-encryption.md | 9 + docs/security/container-hardening.md | 14 + docs/security/container-isolation.md | 71 + docs/security/data-retention.md | 66 + docs/security/secrets.md | 95 + integrations/whmcs/composer.json | 17 + .../modules/servers/hexagamecloud/README.md | 32 +- .../servers/hexagamecloud/hexagamecloud.php | 264 +++ .../modules/servers/hexagamecloud/hooks.php | 68 + .../servers/hexagamecloud/lang/english.php | 35 + .../servers/hexagamecloud/lang/german.php | 35 + .../servers/hexagamecloud/lib/ApiClient.php | 58 + .../servers/hexagamecloud/lib/Idempotency.php | 26 + .../hexagamecloud/lib/ServiceMetadata.php | 61 + .../hexagamecloud/templates/clientarea.tpl | 39 + integrations/whmcs/packaging/build-package.sh | 57 + integrations/whmcs/packaging/manifest.json | 43 + integrations/whmcs/phpunit.xml.dist | 15 + integrations/whmcs/tests/Contract/README.md | 30 + .../whmcs/tests/Unit/IdempotencyTest.php | 53 + packages/catalog/src/curseforge.ts | 74 + packages/catalog/src/index.ts | 9 + packages/contracts/src/abuse.ts | 42 + packages/contracts/src/compliance.ts | 25 + packages/contracts/src/files.ts | 25 + packages/contracts/src/index.ts | 109 + packages/contracts/src/notifications.ts | 51 + packages/contracts/src/organizations.ts | 56 + packages/contracts/src/players.ts | 35 + packages/contracts/src/schedules.ts | 54 + packages/contracts/src/servers.ts | 10 + packages/contracts/src/software.ts | 18 + packages/contracts/src/support.ts | 50 + packages/contracts/src/whmcs-actions.ts | 12 + .../20260705150000_gap_closure/migration.sql | 18 + .../migration.sql | 768 +++++++ packages/database/prisma/schema.prisma | 666 ++++++ packages/database/src/index.ts | 13 + packages/email-templates/package.json | 17 + packages/email-templates/src/index.ts | 102 + packages/email-templates/tsconfig.json | 8 + packages/feature-flags/package.json | 22 + packages/feature-flags/src/index.ts | 57 + packages/feature-flags/tsconfig.json | 8 + packages/otel/package.json | 24 + packages/otel/src/index.ts | 37 + packages/otel/tsconfig.json | 8 + packages/permissions/package.json | 17 + packages/permissions/src/index.ts | 134 ++ packages/permissions/src/permissions.test.ts | 19 + packages/permissions/tsconfig.json | 8 + packages/server-state/package.json | 17 + packages/server-state/src/index.ts | 107 + packages/server-state/src/state.test.ts | 28 + packages/server-state/tsconfig.json | 8 + packages/ui/package.json | 4 + packages/ui/src/components/Dialog.tsx | 112 + packages/ui/src/components/DropdownMenu.tsx | 199 ++ packages/ui/src/index.ts | 33 + pnpm-lock.yaml | 1830 ++++++++++++++++- 225 files changed, 17824 insertions(+), 436 deletions(-) create mode 100644 apps/api/src/abuse/abuse.controller.ts create mode 100644 apps/api/src/abuse/abuse.module.ts create mode 100644 apps/api/src/abuse/abuse.service.ts create mode 100644 apps/api/src/admin/admin-abuse.controller.ts create mode 100644 apps/api/src/admin/admin-audit.controller.ts create mode 100644 apps/api/src/admin/admin-audit.service.ts create mode 100644 apps/api/src/admin/admin-auth.util.ts create mode 100644 apps/api/src/admin/admin-dashboard.controller.ts create mode 100644 apps/api/src/admin/admin-dashboard.service.ts create mode 100644 apps/api/src/admin/admin-jobs.controller.ts create mode 100644 apps/api/src/admin/admin-jobs.service.ts create mode 100644 apps/api/src/admin/admin-users.controller.ts create mode 100644 apps/api/src/admin/admin-users.service.ts create mode 100644 apps/api/src/common/csrf/csrf.controller.ts create mode 100644 apps/api/src/common/csrf/csrf.guard.ts create mode 100644 apps/api/src/common/csrf/csrf.module.ts create mode 100644 apps/api/src/common/csrf/csrf.service.ts create mode 100644 apps/api/src/common/interceptors/idempotency.interceptor.ts create mode 100644 apps/api/src/compliance/compliance.controller.ts create mode 100644 apps/api/src/compliance/compliance.module.ts create mode 100644 apps/api/src/compliance/compliance.service.ts create mode 100644 apps/api/src/feature-flags/feature-flags.controller.ts create mode 100644 apps/api/src/feature-flags/feature-flags.module.ts create mode 100644 apps/api/src/notifications/notifications.controller.ts create mode 100644 apps/api/src/notifications/notifications.module.ts create mode 100644 apps/api/src/notifications/notifications.service.ts create mode 100644 apps/api/src/organizations/organizations.controller.ts create mode 100644 apps/api/src/organizations/organizations.module.ts create mode 100644 apps/api/src/organizations/organizations.service.ts create mode 100644 apps/api/src/servers/members/members.controller.ts create mode 100644 apps/api/src/servers/members/members.module.ts create mode 100644 apps/api/src/servers/schedules/schedules.controller.ts create mode 100644 apps/api/src/servers/schedules/schedules.module.ts create mode 100644 apps/api/src/servers/schedules/schedules.service.ts create mode 100644 apps/api/src/servers/software/software.controller.ts create mode 100644 apps/api/src/servers/software/software.module.ts create mode 100644 apps/api/src/support/support.controller.ts create mode 100644 apps/api/src/support/support.module.ts create mode 100644 apps/api/src/support/support.service.ts create mode 100644 apps/docs/README.md create mode 100644 apps/docs/index.html create mode 100644 apps/docs/package.json create mode 100644 apps/e2e/package.json create mode 100644 apps/e2e/playwright.config.ts create mode 100644 apps/e2e/tests/smoke.spec.ts create mode 100644 apps/e2e/tsconfig.json create mode 100644 apps/web/src/app/[locale]/(account)/billing/page.tsx create mode 100644 apps/web/src/app/[locale]/(account)/layout.tsx create mode 100644 apps/web/src/app/[locale]/(account)/notifications/page.tsx create mode 100644 apps/web/src/app/[locale]/(account)/profile/page.tsx create mode 100644 apps/web/src/app/[locale]/admin/audit/page.tsx create mode 100644 apps/web/src/app/[locale]/admin/jobs/page.tsx create mode 100644 apps/web/src/app/[locale]/admin/layout.tsx create mode 100644 apps/web/src/app/[locale]/admin/nodes/page.tsx create mode 100644 apps/web/src/app/[locale]/admin/page.tsx create mode 100644 apps/web/src/app/[locale]/admin/users/page.tsx create mode 100644 apps/web/src/app/[locale]/docs/page.tsx create mode 100644 apps/web/src/app/[locale]/features/page.tsx create mode 100644 apps/web/src/app/[locale]/impressum/page.tsx create mode 100644 apps/web/src/app/[locale]/pricing/page.tsx create mode 100644 apps/web/src/app/[locale]/privacy/page.tsx create mode 100644 apps/web/src/app/[locale]/servers/[id]/access/page.tsx create mode 100644 apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx create mode 100644 apps/web/src/app/[locale]/servers/[id]/software/page.tsx create mode 100644 apps/web/src/app/[locale]/status/page.tsx create mode 100644 apps/web/src/app/[locale]/terms/page.tsx create mode 100644 apps/web/src/components/account/billing-content.tsx create mode 100644 apps/web/src/components/account/notifications-content.tsx create mode 100644 apps/web/src/components/account/profile-content.tsx create mode 100644 apps/web/src/components/admin/admin-audit-content.tsx create mode 100644 apps/web/src/components/admin/admin-dashboard-content.tsx create mode 100644 apps/web/src/components/admin/admin-jobs-content.tsx create mode 100644 apps/web/src/components/admin/admin-nodes-content.tsx create mode 100644 apps/web/src/components/admin/admin-users-content.tsx create mode 100644 apps/web/src/components/layout/account-nav.tsx create mode 100644 apps/web/src/components/layout/admin-nav.tsx create mode 100644 apps/web/src/components/layout/public-page-shell.tsx create mode 100644 apps/web/src/components/servers/create-server-wizard.tsx create mode 100644 apps/web/src/components/servers/server-access.tsx create mode 100644 apps/web/src/components/servers/server-schedules.tsx create mode 100644 apps/web/src/components/servers/server-software.tsx create mode 100644 apps/web/src/lib/api/admin.ts create mode 100644 apps/web/src/lib/api/compliance.ts create mode 100644 apps/web/src/lib/api/members.ts create mode 100644 apps/web/src/lib/api/notifications.ts create mode 100644 apps/web/src/lib/api/schedules.ts create mode 100644 apps/web/src/lib/api/software.ts create mode 100644 apps/worker/src/handlers/notification-dispatch.ts create mode 100644 apps/worker/src/handlers/schedules-compliance.ts create mode 100644 deploy/compose/compose.prod.yml create mode 100644 deploy/monitoring/grafana/datasources.yml create mode 100644 deploy/monitoring/loki/loki-config.yml create mode 100644 deploy/traefik/dynamic/gamecloud.yml create mode 100644 deploy/traefik/traefik.yml create mode 100644 docs/MASTERPROMPT_GAP_ANALYSIS.md create mode 100644 docs/api/README.md create mode 100644 docs/integrations/whmcs/addons.md create mode 100644 docs/integrations/whmcs/configurable-options.md create mode 100644 docs/integrations/whmcs/configuration.md create mode 100644 docs/integrations/whmcs/installation.md create mode 100644 docs/integrations/whmcs/products.md create mode 100644 docs/integrations/whmcs/security.md create mode 100644 docs/integrations/whmcs/troubleshooting.md create mode 100644 docs/integrations/whmcs/upgrades.md create mode 100644 docs/operations/backup-restore.md create mode 100644 docs/operations/disaster-recovery.md create mode 100644 docs/operations/incident-response.md create mode 100644 docs/operations/node-drain.md create mode 100644 docs/operations/object-storage.md create mode 100644 docs/operations/server-migration.md create mode 100644 docs/operations/traefik.md create mode 100644 docs/operations/upgrades.md create mode 100644 docs/security/backup-encryption.md create mode 100644 docs/security/container-hardening.md create mode 100644 docs/security/container-isolation.md create mode 100644 docs/security/data-retention.md create mode 100644 docs/security/secrets.md create mode 100644 integrations/whmcs/composer.json create mode 100644 integrations/whmcs/modules/servers/hexagamecloud/hooks.php create mode 100644 integrations/whmcs/modules/servers/hexagamecloud/lang/english.php create mode 100644 integrations/whmcs/modules/servers/hexagamecloud/lang/german.php create mode 100644 integrations/whmcs/modules/servers/hexagamecloud/lib/Idempotency.php create mode 100644 integrations/whmcs/modules/servers/hexagamecloud/lib/ServiceMetadata.php create mode 100644 integrations/whmcs/modules/servers/hexagamecloud/templates/clientarea.tpl create mode 100755 integrations/whmcs/packaging/build-package.sh create mode 100644 integrations/whmcs/packaging/manifest.json create mode 100644 integrations/whmcs/phpunit.xml.dist create mode 100644 integrations/whmcs/tests/Contract/README.md create mode 100644 integrations/whmcs/tests/Unit/IdempotencyTest.php create mode 100644 packages/catalog/src/curseforge.ts create mode 100644 packages/contracts/src/abuse.ts create mode 100644 packages/contracts/src/compliance.ts create mode 100644 packages/contracts/src/notifications.ts create mode 100644 packages/contracts/src/organizations.ts create mode 100644 packages/contracts/src/schedules.ts create mode 100644 packages/contracts/src/software.ts create mode 100644 packages/contracts/src/support.ts create mode 100644 packages/contracts/src/whmcs-actions.ts create mode 100644 packages/database/prisma/migrations/20260705150000_gap_closure/migration.sql create mode 100644 packages/database/prisma/migrations/20260705160000_gap_closure_tables/migration.sql create mode 100644 packages/email-templates/package.json create mode 100644 packages/email-templates/src/index.ts create mode 100644 packages/email-templates/tsconfig.json create mode 100644 packages/feature-flags/package.json create mode 100644 packages/feature-flags/src/index.ts create mode 100644 packages/feature-flags/tsconfig.json create mode 100644 packages/otel/package.json create mode 100644 packages/otel/src/index.ts create mode 100644 packages/otel/tsconfig.json create mode 100644 packages/permissions/package.json create mode 100644 packages/permissions/src/index.ts create mode 100644 packages/permissions/src/permissions.test.ts create mode 100644 packages/permissions/tsconfig.json create mode 100644 packages/server-state/package.json create mode 100644 packages/server-state/src/index.ts create mode 100644 packages/server-state/src/state.test.ts create mode 100644 packages/server-state/tsconfig.json create mode 100644 packages/ui/src/components/Dialog.tsx create mode 100644 packages/ui/src/components/DropdownMenu.tsx diff --git a/.env.example b/.env.example index 44cdfd5..2a6b891 100644 --- a/.env.example +++ b/.env.example @@ -89,6 +89,16 @@ STRIPE_WEBHOOK_SECRET= OTEL_EXPORTER_OTLP_ENDPOINT= LOG_LEVEL=info +# --- OIDC (optional) --- +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_REDIRECT_URI=http://localhost:3001/api/v1/auth/oidc/callback + +# --- Notification delivery (optional) --- +NOTIFICATION_WEBHOOK_URL= +NOTIFICATION_DISCORD_WEBHOOK_URL= + # --- Service Ports (development) --- API_PORT=3001 WEB_PORT=3000 diff --git a/apps/api/package.json b/apps/api/package.json index 4106eaf..19ff0d9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,16 +21,22 @@ "@hexahost/contracts": "workspace:*", "@hexahost/database": "workspace:*", "@hexahost/dns": "workspace:*", + "@hexahost/feature-flags": "workspace:*", + "@hexahost/otel": "workspace:*", "@hexahost/integration-auth": "workspace:*", "@hexahost/metering": "workspace:*", + "@hexahost/permissions": "workspace:*", "@hexahost/scheduler": "workspace:*", + "@hexahost/server-state": "workspace:*", "@hexahost/storage": "workspace:*", + "@aws-sdk/client-s3": "^3.750.0", "@nestjs/common": "^11.0.7", "@nestjs/core": "^11.0.7", "@nestjs/platform-fastify": "^11.0.7", "@nestjs/swagger": "^11.0.3", "@nestjs/throttler": "^6.4.0", "bullmq": "^5.34.8", + "cron-parser": "^5.0.4", "fastify": "^5.2.1", "ioredis": "^5.4.2", "nestjs-pino": "^4.3.0", diff --git a/apps/api/src/abuse/abuse.controller.ts b/apps/api/src/abuse/abuse.controller.ts new file mode 100644 index 0000000..60600e0 --- /dev/null +++ b/apps/api/src/abuse/abuse.controller.ts @@ -0,0 +1,29 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { createAbuseReportSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { AbuseService } from './abuse.service'; + +@ApiTags('abuse') +@Controller('abuse/reports') +@UseGuards(SessionGuard) +export class AbuseController { + constructor(private readonly abuseService: AbuseService) {} + + @Post() + @ApiOperation({ summary: 'Submit an abuse report' }) + create( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(createAbuseReportSchema)) body: unknown, + ) { + return this.abuseService.create( + user.id, + body as Parameters[1], + ); + } +} diff --git a/apps/api/src/abuse/abuse.module.ts b/apps/api/src/abuse/abuse.module.ts new file mode 100644 index 0000000..6d509d4 --- /dev/null +++ b/apps/api/src/abuse/abuse.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { AbuseController } from './abuse.controller'; +import { AbuseService } from './abuse.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [AbuseController], + providers: [AbuseService], + exports: [AbuseService], +}) +export class AbuseModule {} diff --git a/apps/api/src/abuse/abuse.service.ts b/apps/api/src/abuse/abuse.service.ts new file mode 100644 index 0000000..f94a6ff --- /dev/null +++ b/apps/api/src/abuse/abuse.service.ts @@ -0,0 +1,78 @@ +import { Injectable } from '@nestjs/common'; + +import type { + AbuseReport, + AbuseReportListResponse, + CreateAbuseReport, + UpdateAbuseReport, +} from '@hexahost/contracts'; +import type { AbuseReport as AbuseReportRow } from '@hexahost/database'; + +import { PrismaService } from '../prisma/prisma.service'; + +function mapReport(report: AbuseReportRow): AbuseReport { + return { + id: report.id, + reporterId: report.reporterId, + targetUserId: report.targetUserId, + serverId: report.serverId, + status: report.status, + reason: report.reason, + details: report.details, + resolution: report.resolution, + createdAt: report.createdAt.toISOString(), + updatedAt: report.updatedAt.toISOString(), + }; +} + +@Injectable() +export class AbuseService { + constructor(private readonly prisma: PrismaService) {} + + async create(reporterId: string, input: CreateAbuseReport): Promise { + const report = await this.prisma.abuseReport.create({ + data: { + reporterId, + targetUserId: input.targetUserId, + serverId: input.serverId, + reason: input.reason.trim(), + details: input.details?.trim(), + }, + }); + + return mapReport(report); + } + + async listAdmin(options?: { + status?: AbuseReport['status']; + limit?: number; + cursor?: string; + }): Promise { + const limit = Math.min(Math.max(options?.limit ?? 50, 1), 100); + + const reports = await this.prisma.abuseReport.findMany({ + where: options?.status ? { status: options.status } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(options?.cursor + ? { cursor: { id: options.cursor }, skip: 1 } + : {}), + }); + + return { + reports: reports.slice(0, limit).map(mapReport), + }; + } + + async updateAdmin(reportId: string, input: UpdateAbuseReport): Promise { + const report = await this.prisma.abuseReport.update({ + where: { id: reportId }, + data: { + status: input.status, + resolution: input.resolution, + }, + }); + + return mapReport(report); + } +} diff --git a/apps/api/src/admin/admin-abuse.controller.ts b/apps/api/src/admin/admin-abuse.controller.ts new file mode 100644 index 0000000..9742d95 --- /dev/null +++ b/apps/api/src/admin/admin-abuse.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { updateAbuseReportSchema } from '@hexahost/contracts'; +import type { AbuseReport } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; +import { AbuseService } from '../abuse/abuse.service'; + +import { assertSuperAdmin } from './admin-auth.util'; + +@ApiTags('admin') +@Controller('admin/abuse') +@UseGuards(SessionGuard) +export class AdminAbuseController { + constructor(private readonly abuseService: AbuseService) {} + + @Get() + @ApiOperation({ summary: 'List abuse reports' }) + list( + @CurrentUser() user: { roles: string[] }, + @Query('status') status?: AbuseReport['status'], + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + ) { + assertSuperAdmin(user.roles); + return this.abuseService.listAdmin({ + status, + limit: limit ? Number(limit) : undefined, + cursor, + }); + } + + @Patch(':reportId') + @ApiOperation({ summary: 'Update an abuse report' }) + update( + @CurrentUser() user: { roles: string[] }, + @Param('reportId', ParseUUIDPipe) reportId: string, + @Body(new ZodValidationPipe(updateAbuseReportSchema)) body: unknown, + ) { + assertSuperAdmin(user.roles); + return this.abuseService.updateAdmin( + reportId, + body as Parameters[1], + ); + } +} diff --git a/apps/api/src/admin/admin-audit.controller.ts b/apps/api/src/admin/admin-audit.controller.ts new file mode 100644 index 0000000..b3e3760 --- /dev/null +++ b/apps/api/src/admin/admin-audit.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminAuditService } from './admin-audit.service'; + +@ApiTags('admin') +@Controller('admin/audit') +@UseGuards(SessionGuard) +export class AdminAuditController { + constructor(private readonly audit: AdminAuditService) {} + + @Get() + @ApiOperation({ summary: 'List audit events' }) + list( + @CurrentUser() user: { roles: string[] }, + @Query('limit') limit?: string, + @Query('cursor') cursor?: string, + @Query('action') action?: string, + ) { + assertSuperAdmin(user.roles); + return this.audit.listAuditEvents({ + limit: limit ? Number(limit) : undefined, + cursor, + action, + }); + } +} diff --git a/apps/api/src/admin/admin-audit.service.ts b/apps/api/src/admin/admin-audit.service.ts new file mode 100644 index 0000000..e9b4dff --- /dev/null +++ b/apps/api/src/admin/admin-audit.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminAuditService { + constructor(private readonly prisma: PrismaService) {} + + async listAuditEvents(query: { + limit?: number; + cursor?: string; + action?: string; + }): Promise<{ + items: Array<{ + id: string; + action: string; + entityType: string | null; + entityId: string | null; + metadata: unknown; + ipAddress: string | null; + createdAt: string; + user: { id: string; email: string; username: string } | null; + }>; + nextCursor: string | null; + }> { + const limit = Math.min(query.limit ?? 50, 100); + + const events = await this.prisma.auditEvent.findMany({ + where: query.action ? { action: query.action } : undefined, + orderBy: { createdAt: 'desc' }, + take: limit + 1, + ...(query.cursor + ? { + cursor: { id: query.cursor }, + skip: 1, + } + : {}), + include: { + user: { + select: { id: true, email: true, username: true }, + }, + }, + }); + + const hasMore = events.length > limit; + const items = hasMore ? events.slice(0, limit) : events; + + return { + items: items.map((event) => ({ + id: event.id, + action: event.action, + entityType: event.entityType, + entityId: event.entityId, + metadata: event.metadata, + ipAddress: event.ipAddress, + createdAt: event.createdAt.toISOString(), + user: event.user, + })), + nextCursor: hasMore ? items[items.length - 1]?.id ?? null : null, + }; + } +} diff --git a/apps/api/src/admin/admin-auth.util.ts b/apps/api/src/admin/admin-auth.util.ts new file mode 100644 index 0000000..9c9ef84 --- /dev/null +++ b/apps/api/src/admin/admin-auth.util.ts @@ -0,0 +1,18 @@ +import { ForbiddenException } from '@nestjs/common'; + +import { UserRole } from '@hexahost/auth'; + +export function assertSuperAdmin(roles: string[]): void { + if (!roles.includes(UserRole.SUPER_ADMIN)) { + throw new ForbiddenException('Super administrator access required'); + } +} + +export function assertPlatformRole( + roles: string[], + allowed: UserRole[], +): void { + if (!allowed.some((role) => roles.includes(role))) { + throw new ForbiddenException('Insufficient platform permissions'); + } +} diff --git a/apps/api/src/admin/admin-dashboard.controller.ts b/apps/api/src/admin/admin-dashboard.controller.ts new file mode 100644 index 0000000..dea497a --- /dev/null +++ b/apps/api/src/admin/admin-dashboard.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminDashboardService } from './admin-dashboard.service'; + +@ApiTags('admin') +@Controller('admin/dashboard') +@UseGuards(SessionGuard) +export class AdminDashboardController { + constructor(private readonly dashboard: AdminDashboardService) {} + + @Get() + @ApiOperation({ summary: 'Admin dashboard statistics' }) + stats(@CurrentUser() user: { roles: string[] }) { + assertSuperAdmin(user.roles); + return this.dashboard.getStats(); + } +} diff --git a/apps/api/src/admin/admin-dashboard.service.ts b/apps/api/src/admin/admin-dashboard.service.ts new file mode 100644 index 0000000..da7b29f --- /dev/null +++ b/apps/api/src/admin/admin-dashboard.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminDashboardService { + constructor(private readonly prisma: PrismaService) {} + + async getStats() { + const [ + activeUsers, + totalServers, + runningServers, + queuedServers, + onlineNodes, + failedJobs24h, + openAbuseReports, + openTickets, + ] = await Promise.all([ + this.prisma.user.count({ where: { status: 'ACTIVE' } }), + this.prisma.gameServer.count({ + where: { status: { not: 'DELETED' } }, + }), + this.prisma.gameServer.count({ where: { status: 'RUNNING' } }), + this.prisma.serverStartQueueEntry.count({ where: { status: 'QUEUED' } }), + this.prisma.gameNode.count({ where: { status: 'ONLINE' } }), + this.prisma.jobRecord.count({ + where: { + status: 'FAILED', + createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) }, + }, + }), + this.prisma.abuseReport.count({ where: { status: 'OPEN' } }), + this.prisma.supportTicket.count({ + where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING'] } }, + }), + ]); + + return { + activeUsers, + totalServers, + runningServers, + queueLength: queuedServers, + onlineNodes, + failedJobs24h, + openAbuseReports, + openTickets, + generatedAt: new Date().toISOString(), + }; + } +} diff --git a/apps/api/src/admin/admin-jobs.controller.ts b/apps/api/src/admin/admin-jobs.controller.ts new file mode 100644 index 0000000..99e54ae --- /dev/null +++ b/apps/api/src/admin/admin-jobs.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminJobsService } from './admin-jobs.service'; + +@ApiTags('admin') +@Controller('admin/jobs') +@UseGuards(SessionGuard) +export class AdminJobsController { + constructor(private readonly jobs: AdminJobsService) {} + + @Get() + @ApiOperation({ summary: 'List background jobs' }) + list( + @CurrentUser() user: { roles: string[] }, + @Query('limit') limit?: string, + @Query('status') status?: string, + @Query('queue') queue?: string, + ) { + assertSuperAdmin(user.roles); + return this.jobs.listJobs({ + limit: limit ? Number(limit) : undefined, + status, + queue, + }); + } +} diff --git a/apps/api/src/admin/admin-jobs.service.ts b/apps/api/src/admin/admin-jobs.service.ts new file mode 100644 index 0000000..6245563 --- /dev/null +++ b/apps/api/src/admin/admin-jobs.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminJobsService { + constructor(private readonly prisma: PrismaService) {} + + async listJobs(query: { limit?: number; status?: string; queue?: string }): Promise<{ + jobs: Array<{ + id: string; + queue: string; + jobName: string; + status: string; + attempts: number; + error: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; + }>; + }> { + const limit = Math.min(query.limit ?? 50, 100); + + const jobs = await this.prisma.jobRecord.findMany({ + where: { + ...(query.status ? { status: query.status as never } : {}), + ...(query.queue ? { queue: query.queue } : {}), + }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + + return { + jobs: jobs.map((job) => ({ + id: job.id, + queue: job.queue, + jobName: job.jobName, + status: job.status, + attempts: job.attempts, + error: job.error, + startedAt: job.startedAt?.toISOString() ?? null, + completedAt: job.completedAt?.toISOString() ?? null, + createdAt: job.createdAt.toISOString(), + })), + }; + } +} diff --git a/apps/api/src/admin/admin-users.controller.ts b/apps/api/src/admin/admin-users.controller.ts new file mode 100644 index 0000000..125e64b --- /dev/null +++ b/apps/api/src/admin/admin-users.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { assertSuperAdmin } from './admin-auth.util'; +import { AdminUsersService } from './admin-users.service'; + +@ApiTags('admin') +@Controller('admin/users') +@UseGuards(SessionGuard) +export class AdminUsersController { + constructor(private readonly users: AdminUsersService) {} + + @Get() + @ApiOperation({ summary: 'Search users' }) + search( + @CurrentUser() user: { roles: string[] }, + @Query('q') q?: string, + @Query('limit') limit?: string, + ) { + assertSuperAdmin(user.roles); + return this.users.searchUsers({ + q, + limit: limit ? Number(limit) : undefined, + }); + } + + @Post(':id/suspend') + @ApiOperation({ summary: 'Suspend a user account' }) + suspend( + @CurrentUser() user: { roles: string[] }, + @Param('id', ParseUUIDPipe) id: string, + ) { + assertSuperAdmin(user.roles); + return this.users.suspendUser(id); + } + + @Post(':id/unsuspend') + @ApiOperation({ summary: 'Unsuspend a user account' }) + unsuspend( + @CurrentUser() user: { roles: string[] }, + @Param('id', ParseUUIDPipe) id: string, + ) { + assertSuperAdmin(user.roles); + return this.users.unsuspendUser(id); + } +} diff --git a/apps/api/src/admin/admin-users.service.ts b/apps/api/src/admin/admin-users.service.ts new file mode 100644 index 0000000..d72276c --- /dev/null +++ b/apps/api/src/admin/admin-users.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class AdminUsersService { + constructor(private readonly prisma: PrismaService) {} + + async searchUsers(query: { q?: string; limit?: number }): Promise<{ + users: Array<{ + id: string; + email: string; + username: string; + displayName: string | null; + status: string; + roles: string[]; + twoFactorEnabled: boolean; + sessionCount: number; + serverCount: number; + createdAt: string; + }>; + }> { + const limit = Math.min(query.limit ?? 25, 100); + const q = query.q?.trim(); + + const users = await this.prisma.user.findMany({ + where: q + ? { + OR: [ + { email: { contains: q, mode: 'insensitive' } }, + { username: { contains: q, mode: 'insensitive' } }, + ], + } + : undefined, + orderBy: { createdAt: 'desc' }, + take: limit, + include: { + platformRoles: { + include: { role: true }, + }, + totpCredential: { select: { enabledAt: true } }, + _count: { select: { sessions: true, gameServers: true } }, + }, + }); + + return { + users: users.map((user) => ({ + id: user.id, + email: user.email, + username: user.username, + displayName: user.displayName, + status: user.status, + roles: user.platformRoles.map((entry) => entry.role.name), + twoFactorEnabled: Boolean(user.totpCredential?.enabledAt), + sessionCount: user._count.sessions, + serverCount: user._count.gameServers, + createdAt: user.createdAt.toISOString(), + })), + }; + } + + async suspendUser(userId: string, reason?: string): Promise<{ id: string; status: string }> { + return this.prisma.user.update({ + where: { id: userId }, + data: { status: 'SUSPENDED' }, + select: { id: true, status: true }, + }); + } + + async unsuspendUser(userId: string): Promise<{ id: string; status: string }> { + return this.prisma.user.update({ + where: { id: userId }, + data: { status: 'ACTIVE', failedLoginAttempts: 0, lockedUntil: null }, + select: { id: true, status: true }, + }); + } +} diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index 3771fc5..fe4ead5 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -1,13 +1,36 @@ import { Module } from '@nestjs/common'; +import { AbuseModule } from '../abuse/abuse.module'; import { AuthModule } from '../auth/auth.module'; +import { AdminAbuseController } from './admin-abuse.controller'; +import { AdminAuditController } from './admin-audit.controller'; +import { AdminAuditService } from './admin-audit.service'; +import { AdminDashboardController } from './admin-dashboard.controller'; +import { AdminDashboardService } from './admin-dashboard.service'; +import { AdminJobsController } from './admin-jobs.controller'; +import { AdminJobsService } from './admin-jobs.service'; import { AdminNodesController } from './admin-nodes.controller'; import { AdminNodesService } from './admin-nodes.service'; +import { AdminUsersController } from './admin-users.controller'; +import { AdminUsersService } from './admin-users.service'; @Module({ - imports: [AuthModule], - controllers: [AdminNodesController], - providers: [AdminNodesService], + imports: [AuthModule, AbuseModule], + controllers: [ + AdminNodesController, + AdminDashboardController, + AdminAuditController, + AdminJobsController, + AdminUsersController, + AdminAbuseController, + ], + providers: [ + AdminNodesService, + AdminDashboardService, + AdminAuditService, + AdminJobsService, + AdminUsersService, + ], }) export class AdminModule {} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index cda1273..35e278b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,5 +1,11 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { AbuseModule } from './abuse/abuse.module'; +import { ComplianceModule } from './compliance/compliance.module'; +import { NotificationsModule } from './notifications/notifications.module'; +import { FeatureFlagsModule } from './feature-flags/feature-flags.module'; +import { OrganizationsModule } from './organizations/organizations.module'; +import { SupportModule } from './support/support.module'; import { RequestIdMiddleware } from './common/middleware/request-id.middleware'; import { HealthModule } from './health/health.module'; import { PrismaModule } from './prisma/prisma.module'; @@ -13,10 +19,14 @@ import { EdgeModule } from './edge/edge.module'; import { ServersModule } from './servers/servers.module'; import { AppConfigModule } from './config/app-config.module'; -import { APP_GUARD } from '@nestjs/core'; +import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { LoggerModule } from 'nestjs-pino'; +import { CsrfModule } from './common/csrf/csrf.module'; +import { CsrfGuard } from './common/csrf/csrf.guard'; +import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor'; + @Module({ imports: [ LoggerModule.forRoot({ @@ -51,12 +61,27 @@ import { LoggerModule } from 'nestjs-pino'; ServersModule, NodesModule, AdminModule, + NotificationsModule, + ComplianceModule, + FeatureFlagsModule, + OrganizationsModule, + SupportModule, + AbuseModule, + CsrfModule, ], providers: [ { provide: APP_GUARD, useClass: ThrottlerGuard, }, + { + provide: APP_GUARD, + useClass: CsrfGuard, + }, + { + provide: APP_INTERCEPTOR, + useClass: IdempotencyInterceptor, + }, ], }) export class AppModule implements NestModule { diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 259342d..b995de6 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Body, Controller, + Get, HttpCode, HttpStatus, Post, @@ -221,4 +222,35 @@ export class AuthController { request.ip, ); } + + @Public() + @Get('oidc/login') + @ApiOperation({ summary: 'Redirect to OIDC provider (optional)' }) + oidcLogin(@Res() reply: FastifyReply) { + const issuer = process.env['OIDC_ISSUER']; + const clientId = process.env['OIDC_CLIENT_ID']; + const redirectUri = process.env['OIDC_REDIRECT_URI']; + if (!issuer || !clientId || !redirectUri) { + throw new BadRequestException('OIDC is not configured'); + } + const url = new URL(`${issuer.replace(/\/$/, '')}/authorize`); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('scope', 'openid email profile'); + return reply.redirect(url.toString(), 302); + } + + @Public() + @Get('oidc/callback') + @ApiOperation({ summary: 'OIDC callback (optional)' }) + async oidcCallback( + @Query('code') code: string | undefined, + @Res() reply: FastifyReply, + ) { + if (!code) { + throw new BadRequestException('Missing authorization code'); + } + return this.authService.completeOidcLogin(code, reply); + } } diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 69bb756..e01b479 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -50,6 +50,13 @@ import { SsoService } from './sso.service'; }, }, ], - exports: [AuthService, SessionService, SessionGuard, AuditService, SsoService], + exports: [ + AuthService, + SessionService, + SessionGuard, + AuditService, + SsoService, + NOTIFICATIONS_QUEUE, + ], }) export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 3a8858a..60d127b 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -712,4 +712,77 @@ export class AuthService { return false; } + + async completeOidcLogin(code: string, reply: FastifyReply): Promise { + const issuer = process.env['OIDC_ISSUER']; + const clientId = process.env['OIDC_CLIENT_ID']; + const clientSecret = process.env['OIDC_CLIENT_SECRET']; + const redirectUri = process.env['OIDC_REDIRECT_URI']; + if (!issuer || !clientId || !clientSecret || !redirectUri) { + throw new BadRequestException('OIDC is not configured'); + } + + const tokenResponse = await fetch(`${issuer.replace(/\/$/, '')}/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + client_secret: clientSecret, + }), + }); + + if (!tokenResponse.ok) { + throw new UnauthorizedException('OIDC token exchange failed'); + } + + const tokenJson = (await tokenResponse.json()) as { access_token?: string }; + if (!tokenJson.access_token) { + throw new UnauthorizedException('OIDC token missing'); + } + + const userInfoResponse = await fetch(`${issuer.replace(/\/$/, '')}/userinfo`, { + headers: { Authorization: `Bearer ${tokenJson.access_token}` }, + }); + if (!userInfoResponse.ok) { + throw new UnauthorizedException('OIDC userinfo failed'); + } + + const profile = (await userInfoResponse.json()) as { + email?: string; + sub?: string; + name?: string; + }; + if (!profile.email) { + throw new BadRequestException('OIDC profile has no email'); + } + + let user = await this.prisma.user.findUnique({ + where: { email: profile.email }, + }); + if (!user) { + const username = generateUsernameFromEmail(profile.email); + const passwordHash = await hashPassword(generateSecureToken(32)); + user = await this.prisma.$transaction(async (tx) => { + const created = await tx.user.create({ + data: { + email: profile.email!, + username, + displayName: profile.name ?? username, + emailVerifiedAt: new Date(), + }, + }); + await tx.userCredential.create({ + data: { userId: created.id, passwordHash }, + }); + return created; + }); + } + + const { token, expiresAt } = await this.sessionService.createSession(user.id); + this.sessionService.setSessionCookie(reply, token, expiresAt); + reply.redirect(`${this.config.APP_URL}/dashboard`, 302); + } } diff --git a/apps/api/src/catalog/catalog.controller.ts b/apps/api/src/catalog/catalog.controller.ts index 6a6a97c..60a5dda 100644 --- a/apps/api/src/catalog/catalog.controller.ts +++ b/apps/api/src/catalog/catalog.controller.ts @@ -8,6 +8,19 @@ import { PrismaService } from '../prisma/prisma.service'; import { CatalogService } from './catalog.service'; +const SOFTWARE_FAMILIES = [ + 'VANILLA', + 'PAPER', + 'PURPUR', + 'FABRIC', + 'FORGE', + 'NEOFORGE', + 'QUILT', + 'BEDROCK_DEDICATED', +] as const; + +const MINECRAFT_VERSIONS = ['1.21.1', '1.21', '1.20.6', '1.20.4']; + @ApiTags('catalog') @Controller('catalog') @UseGuards(SessionGuard) @@ -17,6 +30,55 @@ export class CatalogController { private readonly prisma: PrismaService, ) {} + @Get('software') + @ApiOperation({ summary: 'List supported server software families' }) + listSoftware() { + return { + families: SOFTWARE_FAMILIES.map((family) => ({ id: family, name: family })), + }; + } + + @Get('versions') + @ApiOperation({ summary: 'List supported Minecraft versions' }) + async listVersions(@Query('edition') edition?: string) { + const dbVersions = await this.prisma.minecraftVersion.findMany({ + where: edition ? { edition: edition as 'JAVA' | 'BEDROCK', isActive: true } : { isActive: true }, + orderBy: { version: 'desc' }, + }); + + if (dbVersions.length > 0) { + return { versions: dbVersions.map((entry) => entry.version) }; + } + + return { versions: MINECRAFT_VERSIONS }; + } + + @Get('regions') + @ApiOperation({ summary: 'List available deployment regions' }) + async listRegions(): Promise<{ + regions: Array<{ + id: string; + name: string; + hostname: string; + status: string; + }>; + }> { + const nodes = await this.prisma.gameNode.findMany({ + where: { status: { in: ['ONLINE', 'OFFLINE'] } }, + select: { id: true, name: true, hostname: true, status: true }, + orderBy: { name: 'asc' }, + }); + + return { + regions: nodes.map((node) => ({ + id: node.id, + name: node.name, + hostname: node.hostname, + status: node.status, + })), + }; + } + @Get('projects') @ApiOperation({ summary: 'Search Modrinth projects' }) search(@Query() query: Record) { @@ -64,4 +126,17 @@ export class CatalogController { resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT', ); } + + @Get('curseforge/projects') + @ApiOperation({ summary: 'Search CurseForge mods (requires API key)' }) + searchCurseForge(@Query('q') q?: string, @Query('limit') limit?: string) { + if (!q?.trim()) { + throw new BadRequestException('q is required'); + } + const parsedLimit = limit ? Number.parseInt(limit, 10) : 20; + return this.catalogService.searchCurseForge({ + query: q.trim(), + limit: Number.isFinite(parsedLimit) ? parsedLimit : 20, + }); + } } diff --git a/apps/api/src/catalog/catalog.service.ts b/apps/api/src/catalog/catalog.service.ts index 05900fe..3b21594 100644 --- a/apps/api/src/catalog/catalog.service.ts +++ b/apps/api/src/catalog/catalog.service.ts @@ -9,6 +9,7 @@ import { isVersionCompatible, resolveModrinthLoader, resolveProjectType, + searchCurseForgeMods, searchModrinthProjects, type SoftwareFamily, } from '@hexahost/catalog'; @@ -78,4 +79,26 @@ export class CatalogService { })), }; } + + async searchCurseForge(input: { query: string; limit?: number }) { + try { + const result = await searchCurseForgeMods({ + searchFilter: input.query, + pageSize: input.limit ?? 20, + }); + return { + provider: 'curseforge' as const, + projects: result.data.map((project) => ({ + id: String(project.id), + slug: project.slug, + name: project.name, + description: project.summary, + downloads: project.downloadCount, + iconUrl: project.logo?.thumbnailUrl ?? null, + })), + }; + } catch { + throw new BadRequestException('CurseForge integration is not configured'); + } + } } diff --git a/apps/api/src/common/csrf/csrf.controller.ts b/apps/api/src/common/csrf/csrf.controller.ts new file mode 100644 index 0000000..a6caae4 --- /dev/null +++ b/apps/api/src/common/csrf/csrf.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Res, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply } from 'fastify'; +import { SkipThrottle } from '@nestjs/throttler'; + +import { CsrfService } from './csrf.service'; + +@ApiTags('auth') +@Controller('auth') +@SkipThrottle() +export class CsrfController { + constructor(private readonly csrf: CsrfService) {} + + @Get('csrf') + @ApiOperation({ summary: 'Issue a CSRF token for browser clients' }) + issue(@Res({ passthrough: true }) reply: FastifyReply): { token: string } { + const token = this.csrf.issueToken(reply); + return { token }; + } +} diff --git a/apps/api/src/common/csrf/csrf.guard.ts b/apps/api/src/common/csrf/csrf.guard.ts new file mode 100644 index 0000000..3a06c45 --- /dev/null +++ b/apps/api/src/common/csrf/csrf.guard.ts @@ -0,0 +1,19 @@ +import { + CanActivate, + ExecutionContext, + Injectable, +} from '@nestjs/common'; +import type { FastifyRequest } from 'fastify'; + +import { CsrfService } from './csrf.service'; + +@Injectable() +export class CsrfGuard implements CanActivate { + constructor(private readonly csrf: CsrfService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + this.csrf.validate(request); + return true; + } +} diff --git a/apps/api/src/common/csrf/csrf.module.ts b/apps/api/src/common/csrf/csrf.module.ts new file mode 100644 index 0000000..6aaf7ea --- /dev/null +++ b/apps/api/src/common/csrf/csrf.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AppConfigModule } from '../../config/app-config.module'; + +import { CsrfController } from './csrf.controller'; +import { CsrfGuard } from './csrf.guard'; +import { CsrfService } from './csrf.service'; + +@Module({ + imports: [AppConfigModule], + controllers: [CsrfController], + providers: [CsrfService, CsrfGuard], + exports: [CsrfService, CsrfGuard], +}) +export class CsrfModule {} diff --git a/apps/api/src/common/csrf/csrf.service.ts b/apps/api/src/common/csrf/csrf.service.ts new file mode 100644 index 0000000..5147484 --- /dev/null +++ b/apps/api/src/common/csrf/csrf.service.ts @@ -0,0 +1,77 @@ +import { Inject, Injectable, UnauthorizedException } from '@nestjs/common'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +import type { AppConfig } from '@hexahost/config'; + +import { APP_CONFIG } from '../../config/config.constants'; + +const CSRF_COOKIE = 'hgc_csrf'; +const CSRF_HEADER = 'x-csrf-token'; +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +@Injectable() +export class CsrfService { + constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {} + + issueToken(reply: FastifyReply): string { + const token = randomBytes(32).toString('base64url'); + const signed = this.sign(token); + + reply.setCookie(CSRF_COOKIE, signed, { + httpOnly: false, + sameSite: 'lax', + secure: this.config.APP_ENV === 'production', + path: '/', + }); + + return token; + } + + validate(request: FastifyRequest): void { + const method = request.method.toUpperCase(); + + if (SAFE_METHODS.has(method)) { + return; + } + + const path = request.url.split('?')[0] ?? ''; + + if ( + path.startsWith('/api/v1/integrations/') || + path.startsWith('/api/v1/internal/') || + path.startsWith('/api/v1/webhooks/') || + path.startsWith('/api/v1/health/') + ) { + return; + } + + const cookieToken = this.readSignedCookie(request); + const headerToken = request.headers[CSRF_HEADER]; + + if (typeof headerToken !== 'string' || !cookieToken) { + throw new UnauthorizedException('CSRF token missing'); + } + + const expected = this.sign(headerToken); + const actual = cookieToken; + + if ( + expected.length !== actual.length || + !timingSafeEqual(Buffer.from(expected), Buffer.from(actual)) + ) { + throw new UnauthorizedException('CSRF token invalid'); + } + } + + private readSignedCookie(request: FastifyRequest): string | null { + const cookies = request.cookies as Record; + return cookies[CSRF_COOKIE] ?? null; + } + + private sign(token: string): string { + return createHmac('sha256', this.config.SESSION_SECRET) + .update(token) + .digest('base64url'); + } +} diff --git a/apps/api/src/common/interceptors/idempotency.interceptor.ts b/apps/api/src/common/interceptors/idempotency.interceptor.ts new file mode 100644 index 0000000..0e769f9 --- /dev/null +++ b/apps/api/src/common/interceptors/idempotency.interceptor.ts @@ -0,0 +1,71 @@ +import { + BadRequestException, + CallHandler, + ExecutionContext, + Inject, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, of } from 'rxjs'; +import Redis from 'ioredis'; + +import { REDIS_CLIENT } from '../../servers/servers.service'; + +const IDEMPOTENCY_HEADER = 'idempotency-key'; +const TTL_SECONDS = 86_400; + +@Injectable() +export class IdempotencyInterceptor implements NestInterceptor { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {} + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const request = context.switchToHttp().getRequest<{ + method?: string; + headers?: Record; + user?: { id?: string }; + url?: string; + }>(); + + if (!request.method || !['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method)) { + return next.handle(); + } + + const rawKey = request.headers?.[IDEMPOTENCY_HEADER]; + const key = Array.isArray(rawKey) ? rawKey[0] : rawKey; + if (!key || key.length < 8) { + return next.handle(); + } + + const userId = request.user?.id ?? 'anonymous'; + const cacheKey = `idempotency:panel:${userId}:${key}:${request.method}:${request.url ?? ''}`; + const cached = await this.redis.get(cacheKey); + if (cached) { + return of(JSON.parse(cached) as unknown); + } + + return new Observable((subscriber) => { + next.handle().subscribe({ + next: (value) => { + void this.redis + .set(cacheKey, JSON.stringify(value), 'EX', TTL_SECONDS) + .catch(() => undefined); + subscriber.next(value); + subscriber.complete(); + }, + error: (error: unknown) => subscriber.error(error), + }); + }); + } +} + +export function assertIdempotencyKey(headers: Record): string { + const raw = headers[IDEMPOTENCY_HEADER]; + const key = Array.isArray(raw) ? raw[0] : raw; + if (typeof key !== 'string' || key.length < 8) { + throw new BadRequestException('Idempotency-Key header is required'); + } + return key; +} diff --git a/apps/api/src/compliance/compliance.controller.ts b/apps/api/src/compliance/compliance.controller.ts new file mode 100644 index 0000000..0e9e607 --- /dev/null +++ b/apps/api/src/compliance/compliance.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; + +import { requestAccountDeletionSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { ComplianceService } from './compliance.service'; + +@ApiTags('account') +@Controller('account/compliance') +@UseGuards(SessionGuard) +export class ComplianceController { + constructor(private readonly complianceService: ComplianceService) {} + + @Post('export') + requestExport(@CurrentUser() user: { id: string }) { + return this.complianceService.requestDataExport(user.id); + } + + @Get('export') + listExports(@CurrentUser() user: { id: string }) { + return this.complianceService.listDataExports(user.id); + } + + @Get('export/:exportId/download') + downloadExport( + @CurrentUser() user: { id: string }, + @Param('exportId', ParseUUIDPipe) exportId: string, + ) { + return this.complianceService.getExportDownload(user.id, exportId); + } + + @Post('deletion') + requestDeletion( + @CurrentUser() user: { id: string; email: string }, + @Body(new ZodValidationPipe(requestAccountDeletionSchema)) body: unknown, + ) { + const parsed = body as { confirmEmail: string }; + return this.complianceService.requestAccountDeletion(user.id, parsed.confirmEmail); + } + + @Get('deletion') + deletionStatus(@CurrentUser() user: { id: string }) { + return this.complianceService.getAccountDeletionStatus(user.id); + } + + @Delete('deletion') + cancelDeletion(@CurrentUser() user: { id: string }) { + return this.complianceService.cancelAccountDeletion(user.id); + } +} diff --git a/apps/api/src/compliance/compliance.module.ts b/apps/api/src/compliance/compliance.module.ts new file mode 100644 index 0000000..2774730 --- /dev/null +++ b/apps/api/src/compliance/compliance.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ServersModule } from '../servers/servers.module'; + +import { ComplianceController } from './compliance.controller'; +import { ComplianceService } from './compliance.service'; + +@Module({ + imports: [PrismaModule, AuthModule, ServersModule], + controllers: [ComplianceController], + providers: [ComplianceService], + exports: [ComplianceService], +}) +export class ComplianceModule {} diff --git a/apps/api/src/compliance/compliance.service.ts b/apps/api/src/compliance/compliance.service.ts new file mode 100644 index 0000000..a9097ff --- /dev/null +++ b/apps/api/src/compliance/compliance.service.ts @@ -0,0 +1,400 @@ +import { randomUUID } from 'node:crypto'; + +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Queue } from 'bullmq'; +import type Redis from 'ioredis'; + +import { getConfig } from '@hexahost/config'; +import type { GameServerStatus } from '@hexahost/server-state'; +import { ALLOWED_TRANSITIONS } from '@hexahost/server-state'; +import { + createPresignedGetUrl, + createS3Client, + loadStorageConfig, +} from '@hexahost/storage'; + +import { PrismaService } from '../prisma/prisma.service'; +import { + REDIS_CLIENT, + SERVER_LIFECYCLE_QUEUE, +} from '../servers/servers.service'; + +const DELETION_GRACE_DAYS = 14; +const EXPORT_TTL_DAYS = 7; +const EXPORT_BLOB_PREFIX = 'export:blob:'; + +function isStorageConfigured(): boolean { + return Boolean(process.env['S3_ENDPOINT']?.trim()); +} + +function exportObjectKey(userId: string, exportId: string): string { + return `users/${userId}/exports/${exportId}.json`; +} + +@Injectable() +export class ComplianceService { + constructor( + private readonly prisma: PrismaService, + @Inject(REDIS_CLIENT) private readonly redis: Redis, + @Inject(SERVER_LIFECYCLE_QUEUE) + private readonly lifecycleQueue: Queue, + ) {} + + async requestDataExport(userId: string) { + const existing = await this.prisma.dataExportRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'PROCESSING', 'READY'] } }, + orderBy: { createdAt: 'desc' }, + }); + if (existing) { + return this.mapExport(existing); + } + + const created = await this.prisma.dataExportRequest.create({ + data: { userId, status: 'PENDING' }, + }); + + void this.processDataExport(created.id).catch(() => undefined); + + return this.mapExport(created); + } + + async listDataExports(userId: string) { + const rows = await this.prisma.dataExportRequest.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 10, + }); + return { exports: rows.map((row) => this.mapExport(row)) }; + } + + async getExportDownload(userId: string, exportId: string) { + const row = await this.prisma.dataExportRequest.findFirst({ + where: { id: exportId, userId }, + }); + if (!row || row.status !== 'READY') { + throw new NotFoundException('Export not available'); + } + if (row.expiresAt && row.expiresAt <= new Date()) { + throw new NotFoundException('Export has expired'); + } + + if (row.downloadUrl?.startsWith('s3://')) { + const [, bucket, ...keyParts] = row.downloadUrl.split('/'); + const key = keyParts.join('/'); + const config = loadStorageConfig(); + const client = createS3Client(config); + const url = await createPresignedGetUrl(client, bucket!, key, 3600); + return { url, expiresInSeconds: 3600 }; + } + + const blob = await this.redis.get(`${EXPORT_BLOB_PREFIX}${exportId}`); + if (!blob) { + throw new NotFoundException('Export data not found'); + } + + return { + contentType: 'application/json', + data: JSON.parse(blob) as unknown, + }; + } + + async requestAccountDeletion(userId: string, confirmEmail: string) { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.email !== confirmEmail) { + throw new BadRequestException('Email confirmation does not match'); + } + + const scheduledFor = new Date(); + scheduledFor.setDate(scheduledFor.getDate() + DELETION_GRACE_DAYS); + + const existing = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } }, + }); + if (existing) { + return this.mapDeletion(existing); + } + + const created = await this.prisma.accountDeletionRequest.create({ + data: { + userId, + status: 'SCHEDULED', + scheduledFor, + }, + }); + return this.mapDeletion(created); + } + + async cancelAccountDeletion(userId: string) { + const pending = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } }, + }); + if (!pending) { + throw new NotFoundException('No pending deletion request'); + } + const updated = await this.prisma.accountDeletionRequest.update({ + where: { id: pending.id }, + data: { status: 'CANCELLED' }, + }); + return this.mapDeletion(updated); + } + + async getAccountDeletionStatus(userId: string) { + const pending = await this.prisma.accountDeletionRequest.findFirst({ + where: { userId, status: { in: ['PENDING', 'SCHEDULED'] } }, + orderBy: { createdAt: 'desc' }, + }); + return { request: pending ? this.mapDeletion(pending) : null }; + } + + async processPendingExports(limit = 10): Promise { + const pending = await this.prisma.dataExportRequest.findMany({ + where: { status: 'PENDING' }, + take: limit, + }); + + for (const row of pending) { + await this.processDataExport(row.id); + } + + return pending.length; + } + + async processDueDeletions(limit = 10): Promise { + const due = await this.prisma.accountDeletionRequest.findMany({ + where: { status: 'SCHEDULED', scheduledFor: { lte: new Date() } }, + take: limit, + }); + + for (const row of due) { + await this.executeAccountDeletion(row.id); + } + + return due.length; + } + + async processDataExport(exportId: string): Promise { + const row = await this.prisma.dataExportRequest.findUnique({ + where: { id: exportId }, + }); + if (!row || !['PENDING', 'PROCESSING'].includes(row.status)) { + return; + } + + await this.prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'PROCESSING' }, + }); + + try { + const payload = await this.collectUserExportData(row.userId); + const json = JSON.stringify(payload, null, 2); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + EXPORT_TTL_DAYS); + + let downloadUrl: string; + + if (isStorageConfigured()) { + const config = loadStorageConfig(); + const client = createS3Client(config); + const key = exportObjectKey(row.userId, exportId); + await client.send( + new PutObjectCommand({ + Bucket: config.S3_BUCKET, + Key: key, + Body: json, + ContentType: 'application/json', + }), + ); + downloadUrl = `s3://${config.S3_BUCKET}/${key}`; + } else { + await this.redis.set( + `${EXPORT_BLOB_PREFIX}${exportId}`, + json, + 'EX', + EXPORT_TTL_DAYS * 24 * 60 * 60, + ); + const config = getConfig(); + downloadUrl = `${config.API_URL}/account/compliance/export/${exportId}/download`; + } + + await this.prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { + status: 'READY', + downloadUrl, + expiresAt, + completedAt: new Date(), + }, + }); + } catch { + await this.prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'FAILED' }, + }); + } + } + + async executeAccountDeletion(requestId: string): Promise { + const request = await this.prisma.accountDeletionRequest.findUnique({ + where: { id: requestId }, + }); + if (!request || request.status !== 'SCHEDULED') { + return; + } + + const userId = request.userId; + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.status === 'DELETED') { + await this.prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }); + return; + } + + const servers = await this.prisma.gameServer.findMany({ + where: { userId, status: { not: 'DELETED' } }, + }); + + for (const server of servers) { + if (server.status === 'RUNNING' || server.status === 'STARTING') { + continue; + } + + if (ALLOWED_TRANSITIONS[server.status as GameServerStatus]?.includes('DELETING')) { + const correlationId = randomUUID(); + await this.prisma.gameServer.update({ + where: { id: server.id }, + data: { status: 'DELETING' }, + }); + await this.lifecycleQueue.add( + 'delete-server', + { serverId: server.id, action: 'delete', correlationId }, + { jobId: correlationId }, + ); + } + } + + const anonymizedId = randomUUID().slice(0, 8); + await this.prisma.$transaction([ + this.prisma.userSession.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + this.prisma.user.update({ + where: { id: userId }, + data: { + email: `deleted-${anonymizedId}@deleted.invalid`, + username: `deleted-${anonymizedId}`, + displayName: null, + status: 'DELETED', + }, + }), + this.prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }), + ]); + } + + private async collectUserExportData(userId: string) { + const [user, servers, auditEvents] = await Promise.all([ + this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + displayName: true, + status: true, + emailVerifiedAt: true, + createdAt: true, + updatedAt: true, + }, + }), + this.prisma.gameServer.findMany({ + where: { userId }, + select: { + id: true, + name: true, + status: true, + edition: true, + softwareFamily: true, + minecraftVersion: true, + ramMb: true, + hostPort: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.auditEvent.findMany({ + where: { userId }, + select: { + action: true, + entityType: true, + entityId: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: 500, + }), + ]); + + const actionCounts = auditEvents.reduce>((acc, event) => { + acc[event.action] = (acc[event.action] ?? 0) + 1; + return acc; + }, {}); + + return { + exportedAt: new Date().toISOString(), + profile: user, + servers, + auditSummary: { + totalEvents: auditEvents.length, + actionCounts, + recentEvents: auditEvents.slice(0, 50), + }, + }; + } + + private mapExport(row: { + id: string; + status: string; + downloadUrl: string | null; + expiresAt: Date | null; + completedAt: Date | null; + createdAt: Date; + }) { + return { + id: row.id, + status: row.status as 'PENDING' | 'PROCESSING' | 'READY' | 'FAILED' | 'EXPIRED', + downloadUrl: row.downloadUrl, + expiresAt: row.expiresAt?.toISOString() ?? null, + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } + + private mapDeletion(row: { + id: string; + status: string; + scheduledFor: Date; + completedAt: Date | null; + createdAt: Date; + }) { + return { + id: row.id, + status: row.status as 'PENDING' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED', + scheduledFor: row.scheduledFor.toISOString(), + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + }; + } +} diff --git a/apps/api/src/feature-flags/feature-flags.controller.ts b/apps/api/src/feature-flags/feature-flags.controller.ts new file mode 100644 index 0000000..a0c4b69 --- /dev/null +++ b/apps/api/src/feature-flags/feature-flags.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { FeatureFlagService } from '@hexahost/feature-flags'; + +import { Public } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; + +import { FEATURE_FLAG_SERVICE } from './feature-flags.module'; + +@ApiTags('system') +@Controller('feature-flags') +export class FeatureFlagsController { + constructor( + @Inject(FEATURE_FLAG_SERVICE) private readonly flags: FeatureFlagService, + ) {} + + @Public() + @Get() + @ApiOperation({ summary: 'List public feature flags' }) + async list() { + const flags = await this.flags.listFlags(); + return { + flags: flags.map((flag) => ({ + key: flag.key, + enabled: flag.enabled, + })), + }; + } + + @Get('admin') + @UseGuards(SessionGuard) + @ApiOperation({ summary: 'List feature flags with metadata (authenticated)' }) + async listDetailed() { + return { flags: await this.flags.listFlags() }; + } +} diff --git a/apps/api/src/feature-flags/feature-flags.module.ts b/apps/api/src/feature-flags/feature-flags.module.ts new file mode 100644 index 0000000..bfa7f05 --- /dev/null +++ b/apps/api/src/feature-flags/feature-flags.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; + +import { FeatureFlagService } from '@hexahost/feature-flags'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { PrismaService } from '../prisma/prisma.service'; + +import { FeatureFlagsController } from './feature-flags.controller'; + +export const FEATURE_FLAG_SERVICE = Symbol('FEATURE_FLAG_SERVICE'); + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [FeatureFlagsController], + providers: [ + { + provide: FEATURE_FLAG_SERVICE, + useFactory: (prisma: PrismaService) => new FeatureFlagService(prisma), + inject: [PrismaService], + }, + ], + exports: [FEATURE_FLAG_SERVICE], +}) +export class FeatureFlagsModule {} diff --git a/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts b/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts index 73434fa..e5bb8be 100644 --- a/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts +++ b/apps/api/src/integrations/whmcs/whmcs-integration.controller.ts @@ -109,6 +109,21 @@ export class WhmcsIntegrationController { ); } + @Put('users/:externalUserId') + upsertUser( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalUserId') externalUserId: string, + @Body() body: unknown, + ) { + const parsed = whmcsUpsertClientRequestSchema.parse(body); + return this.whmcs.upsertClient( + request.whmcsInstallation!, + externalUserId, + parsed, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + @Post('services') provisionService(@Req() request: WhmcsAuthenticatedRequest, @Body() body: unknown) { const parsed = whmcsProvisionServiceRequestSchema.parse(body); @@ -181,6 +196,21 @@ export class WhmcsIntegrationController { ); } + @Post('services/:externalServiceId/renew') + renewService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + @Body() body: unknown, + ) { + const parsed = (body ?? {}) as { invoiceId?: string; monthlyCreditGrant?: number }; + return this.whmcs.renewService( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + parsed, + ); + } + @Post('services/:externalServiceId/sso') createServiceSso( @Req() request: WhmcsAuthenticatedRequest, @@ -284,6 +314,54 @@ export class WhmcsIntegrationController { headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, ); } + + @Post('services/:externalServiceId/actions/start') + startService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.startServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + + @Post('services/:externalServiceId/actions/stop') + stopService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.stopServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + + @Post('services/:externalServiceId/actions/restart') + restartService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.restartServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } + + @Post('services/:externalServiceId/actions/backup') + backupService( + @Req() request: WhmcsAuthenticatedRequest, + @Param('externalServiceId') externalServiceId: string, + ) { + return this.whmcs.backupServiceAction( + request.whmcsInstallation!, + externalServiceId, + headerValue(request, INTEGRATION_HEADERS.idempotencyKey)!, + ); + } } function headerValue(request: WhmcsAuthenticatedRequest, header: string): string | undefined { diff --git a/apps/api/src/integrations/whmcs/whmcs-integration.module.ts b/apps/api/src/integrations/whmcs/whmcs-integration.module.ts index 9b50fef..e0a9059 100644 --- a/apps/api/src/integrations/whmcs/whmcs-integration.module.ts +++ b/apps/api/src/integrations/whmcs/whmcs-integration.module.ts @@ -3,6 +3,7 @@ import Redis from 'ioredis'; import { getConfig } from '@hexahost/config'; +import { BackupsModule } from '../../servers/backups/backups.module'; import { BillingModule } from '../../billing/billing.module'; import { AuthModule } from '../../auth/auth.module'; import { ServersModule } from '../../servers/servers.module'; @@ -20,7 +21,7 @@ import { WhmcsUsageService } from './whmcs-usage.service'; export { INTEGRATIONS_REDIS } from './whmcs-integration.constants'; @Module({ - imports: [ServersModule, BillingModule, AuthModule], + imports: [ServersModule, BackupsModule, BillingModule, AuthModule], controllers: [WhmcsIntegrationController], providers: [ WhmcsIntegrationService, diff --git a/apps/api/src/integrations/whmcs/whmcs-integration.service.ts b/apps/api/src/integrations/whmcs/whmcs-integration.service.ts index eaed280..00561a0 100644 --- a/apps/api/src/integrations/whmcs/whmcs-integration.service.ts +++ b/apps/api/src/integrations/whmcs/whmcs-integration.service.ts @@ -20,6 +20,7 @@ import type { GameServer } from '@hexahost/database'; import { BillingService } from '../../billing/billing.service'; import { getBillingProvider } from '../../billing/billing-provider'; import { PrismaService } from '../../prisma/prisma.service'; +import { BackupsService } from '../../servers/backups/backups.service'; import { ServersService } from '../../servers/servers.service'; import type { WhmcsInstallationContext } from './whmcs-integration.guard'; @@ -34,6 +35,7 @@ export class WhmcsIntegrationService { constructor( private readonly prisma: PrismaService, private readonly serversService: ServersService, + private readonly backupsService: BackupsService, private readonly billing: BillingService, ) {} @@ -356,6 +358,67 @@ export class WhmcsIntegrationService { return response; } + async renewService( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + body: { invoiceId?: string; monthlyCreditGrant?: number }, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const server = link.server; + + if (body.monthlyCreditGrant && body.monthlyCreditGrant > 0) { + const wallet = await this.prisma.creditWallet.findUnique({ + where: { userId: server.userId }, + }); + + if (wallet) { + await this.prisma.$transaction([ + this.prisma.creditWallet.update({ + where: { id: wallet.id }, + data: { balance: { increment: body.monthlyCreditGrant } }, + }), + this.prisma.creditTransaction.create({ + data: { + walletId: wallet.id, + amount: body.monthlyCreditGrant, + type: 'GRANT', + referenceType: 'whmcs_renew', + referenceId: body.invoiceId ?? externalServiceId, + idempotencyKey, + }, + }), + ]); + } + } + + const updatedLink = await this.prisma.whmcsServiceLink.update({ + where: { id: link.id }, + data: { status: 'ACTIVE', suspendedAt: null }, + }); + + const response = this.toServiceResponse( + externalServiceId, + updatedLink.status, + server, + ); + + await this.recordOperation( + installation.id, + idempotencyKey, + 'service:renew', + response, + externalServiceId, + ); + + return response; + } + async terminateService( installation: InstallationRef, externalServiceId: string, @@ -530,4 +593,93 @@ export class WhmcsIntegrationService { }, }; } + + async startServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const result = await this.serversService.startServerInternal( + link.server.id, + `whmcs:start:${externalServiceId}`, + ); + + const response = this.toServiceResponse( + externalServiceId, + link.status, + result.server as unknown as GameServer, + ); + await this.recordOperation(installation.id, idempotencyKey, 'service:start', response, externalServiceId); + return response; + } + + async stopServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const result = await this.serversService.stopServer(link.server.userId, link.server.id); + const response = this.toServiceResponse( + externalServiceId, + link.status, + result.server as unknown as GameServer, + ); + await this.recordOperation(installation.id, idempotencyKey, 'service:stop', response, externalServiceId); + return response; + } + + async restartServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as WhmcsServiceResponse; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const result = await this.serversService.restartServer(link.server.userId, link.server.id); + const response = this.toServiceResponse( + externalServiceId, + link.status, + result.server as unknown as GameServer, + ); + await this.recordOperation(installation.id, idempotencyKey, 'service:restart', response, externalServiceId); + return response; + } + + async backupServiceAction( + installation: InstallationRef, + externalServiceId: string, + idempotencyKey: string, + ): Promise<{ externalServiceId: string; jobId: string }> { + const existingOp = await this.findCompletedOperation(installation.id, idempotencyKey); + if (existingOp?.result) { + return existingOp.result as { externalServiceId: string; jobId: string }; + } + + const link = await this.findServiceLink(installation.id, externalServiceId); + const backup = await this.backupsService.createBackup(link.server.userId, link.server.id, { + label: 'WHMCS backup', + }); + const response = { + externalServiceId, + jobId: backup.jobId ?? idempotencyKey, + }; + await this.recordOperation(installation.id, idempotencyKey, 'service:backup', response, externalServiceId); + return response; + } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b198a6f..ba048dc 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -11,12 +11,14 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { Logger } from 'nestjs-pino'; import { validateConfig } from '@hexahost/config'; +import { initOpenTelemetry, shutdownOpenTelemetry } from '@hexahost/otel'; import { AppModule } from './app.module'; import { Rfc7807ExceptionFilter } from './common/filters/rfc7807-exception.filter'; async function bootstrap(): Promise { const config = validateConfig(); + initOpenTelemetry('hexahost-api'); const app = await NestFactory.create( AppModule, diff --git a/apps/api/src/nodes/nodes.service.ts b/apps/api/src/nodes/nodes.service.ts index 028600f..95d296d 100644 --- a/apps/api/src/nodes/nodes.service.ts +++ b/apps/api/src/nodes/nodes.service.ts @@ -167,6 +167,9 @@ export class NodesService { case 'server.files.list.result': case 'server.files.read.result': case 'server.files.write.result': + case 'server.files.delete.result': + case 'server.files.archive.result': + case 'server.files.unarchive.result': case 'server.command.result': await this.handleDirectResult(envelope); break; diff --git a/apps/api/src/notifications/notifications.controller.ts b/apps/api/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..e1d38bc --- /dev/null +++ b/apps/api/src/notifications/notifications.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { updateNotificationPreferencesSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { NotificationsService } from './notifications.service'; + +@ApiTags('account') +@Controller('account/notifications') +@UseGuards(SessionGuard) +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + @Get() + list(@CurrentUser() user: { id: string }) { + return this.notificationsService.list(user.id); + } + + @Post(':id/read') + markRead( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.notificationsService.markRead(user.id, id); + } + + @Post('read-all') + markAllRead(@CurrentUser() user: { id: string }) { + return this.notificationsService.markAllRead(user.id); + } + + @Get('preferences') + getPreferences(@CurrentUser() user: { id: string }) { + return this.notificationsService.getPreferences(user.id); + } + + @Patch('preferences') + @ApiOperation({ summary: 'Update notification channel preferences' }) + updatePreferences( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(updateNotificationPreferencesSchema)) body: unknown, + ) { + return this.notificationsService.updatePreferences( + user.id, + body as Parameters[1], + ); + } +} diff --git a/apps/api/src/notifications/notifications.module.ts b/apps/api/src/notifications/notifications.module.ts new file mode 100644 index 0000000..de25abc --- /dev/null +++ b/apps/api/src/notifications/notifications.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { NotificationsController } from './notifications.controller'; +import { NotificationsService } from './notifications.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/apps/api/src/notifications/notifications.service.ts b/apps/api/src/notifications/notifications.service.ts new file mode 100644 index 0000000..f9bb0cd --- /dev/null +++ b/apps/api/src/notifications/notifications.service.ts @@ -0,0 +1,166 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Queue } from 'bullmq'; + +import type { + NotificationListResponse, + UpdateNotificationPreferences, +} from '@hexahost/contracts'; + +import { NOTIFICATIONS_QUEUE } from '../auth/auth.service'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class NotificationsService { + constructor( + private readonly prisma: PrismaService, + @Inject(NOTIFICATIONS_QUEUE) private readonly notificationsQueue: Queue, + ) {} + + async list(userId: string): Promise { + const [notifications, unreadCount] = await Promise.all([ + this.prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: 100, + }), + this.prisma.notification.count({ + where: { userId, readAt: null }, + }), + ]); + + return { + unreadCount, + notifications: notifications.map((n) => ({ + id: n.id, + type: n.type, + channel: n.channel, + title: n.title, + body: n.body, + readAt: n.readAt?.toISOString() ?? null, + createdAt: n.createdAt.toISOString(), + })), + }; + } + + async markRead(userId: string, notificationId: string) { + await this.prisma.notification.updateMany({ + where: { id: notificationId, userId }, + data: { readAt: new Date() }, + }); + return { read: true }; + } + + async markAllRead(userId: string) { + await this.prisma.notification.updateMany({ + where: { userId, readAt: null }, + data: { readAt: new Date() }, + }); + return { read: true }; + } + + async getPreferences(userId: string): Promise<{ + preferences: Array<{ type: string; channel: string; enabled: boolean }>; + }> { + const prefs = await this.prisma.notificationPreference.findMany({ + where: { userId }, + }); + return { + preferences: prefs.map((p) => ({ + type: p.type, + channel: p.channel, + enabled: p.enabled, + })), + }; + } + + async updatePreferences( + userId: string, + input: UpdateNotificationPreferences, + ): Promise<{ preferences: Array<{ type: string; channel: string; enabled: boolean }> }> { + await this.prisma.$transaction( + input.preferences.map((pref) => + this.prisma.notificationPreference.upsert({ + where: { + userId_type_channel: { + userId, + type: pref.type, + channel: pref.channel, + }, + }, + create: { + userId, + type: pref.type, + channel: pref.channel, + enabled: pref.enabled, + }, + update: { enabled: pref.enabled }, + }), + ), + ); + return this.getPreferences(userId); + } + + async createInApp( + userId: string, + input: { type: NotificationListResponse['notifications'][number]['type']; title: string; body: string }, + ): Promise<{ id: string }> { + const created = await this.prisma.notification.create({ + data: { + userId, + type: input.type, + channel: 'IN_APP', + title: input.title, + body: input.body, + }, + }); + return { id: created.id }; + } + + async dispatch( + userId: string, + input: { + type: NotificationListResponse['notifications'][number]['type']; + title: string; + body: string; + metadata?: Record; + }, + ): Promise { + await this.createInApp(userId, input); + + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { email: true }, + }); + if (!user) { + return; + } + + const prefs = await this.prisma.notificationPreference.findMany({ + where: { userId, type: input.type, enabled: true }, + }); + const enabledChannels = new Set(prefs.map((p) => p.channel)); + + const channels = ['EMAIL', 'WEBHOOK', 'DISCORD'] as const; + for (const channel of channels) { + if (!enabledChannels.has(channel) && prefs.length > 0) { + continue; + } + + await this.notificationsQueue.add( + 'dispatch-notification', + { + userId, + type: input.type, + channel, + title: input.title, + body: input.body, + metadata: { + ...input.metadata, + email: user.email, + }, + }, + { removeOnComplete: true }, + ); + } + } +} diff --git a/apps/api/src/organizations/organizations.controller.ts b/apps/api/src/organizations/organizations.controller.ts new file mode 100644 index 0000000..448df62 --- /dev/null +++ b/apps/api/src/organizations/organizations.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + createOrganizationSchema, + inviteOrganizationMemberSchema, +} from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { OrganizationsService } from './organizations.service'; + +@ApiTags('organizations') +@Controller('organizations') +@UseGuards(SessionGuard) +export class OrganizationsController { + constructor(private readonly organizationsService: OrganizationsService) {} + + @Get() + @ApiOperation({ summary: "List current user's organizations" }) + list(@CurrentUser() user: { id: string }) { + return this.organizationsService.list(user.id); + } + + @Post() + @ApiOperation({ summary: 'Create a new organization' }) + create( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(createOrganizationSchema)) body: unknown, + ) { + return this.organizationsService.create( + user.id, + body as Parameters[1], + ); + } + + @Get(':organizationId/members') + @ApiOperation({ summary: 'List organization members' }) + listMembers( + @CurrentUser() user: { id: string }, + @Param('organizationId', ParseUUIDPipe) organizationId: string, + ) { + return this.organizationsService.listMembers(user.id, organizationId); + } + + @Post(':organizationId/members') + @ApiOperation({ summary: 'Invite a member to the organization' }) + inviteMember( + @CurrentUser() user: { id: string }, + @Param('organizationId', ParseUUIDPipe) organizationId: string, + @Body(new ZodValidationPipe(inviteOrganizationMemberSchema)) body: unknown, + ) { + return this.organizationsService.inviteMember( + user.id, + organizationId, + body as Parameters[2], + ); + } +} diff --git a/apps/api/src/organizations/organizations.module.ts b/apps/api/src/organizations/organizations.module.ts new file mode 100644 index 0000000..ab1660b --- /dev/null +++ b/apps/api/src/organizations/organizations.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { OrganizationsController } from './organizations.controller'; +import { OrganizationsService } from './organizations.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [OrganizationsController], + providers: [OrganizationsService], + exports: [OrganizationsService], +}) +export class OrganizationsModule {} diff --git a/apps/api/src/organizations/organizations.service.ts b/apps/api/src/organizations/organizations.service.ts new file mode 100644 index 0000000..219619e --- /dev/null +++ b/apps/api/src/organizations/organizations.service.ts @@ -0,0 +1,217 @@ +import { randomUUID } from 'node:crypto'; + +import { + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import { generateSecureToken, hashToken } from '@hexahost/auth'; +import type { + CreateOrganization, + InviteOrganizationMember, + Organization, + OrganizationInviteResponse, + OrganizationListResponse, + OrganizationMemberListResponse, +} from '@hexahost/contracts'; +import type { + Organization as OrganizationRow, + OrganizationMember, + OrganizationMemberRole, +} from '@hexahost/database'; + +import { PrismaService } from '../prisma/prisma.service'; + +const INVITE_TTL_DAYS = 7; + +function slugifyName(name: string): string { + return name + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); +} + +function mapOrganization( + org: OrganizationRow, + role: OrganizationMemberRole, +): Organization { + return { + id: org.id, + name: org.name, + slug: org.slug, + role, + createdAt: org.createdAt.toISOString(), + }; +} + +@Injectable() +export class OrganizationsService { + constructor(private readonly prisma: PrismaService) {} + + async list(userId: string): Promise { + const memberships = await this.prisma.organizationMember.findMany({ + where: { userId }, + include: { organization: true }, + orderBy: { createdAt: 'asc' }, + }); + + return { + organizations: memberships.map((membership) => + mapOrganization(membership.organization, membership.role), + ), + }; + } + + async create(userId: string, input: CreateOrganization): Promise { + const baseSlug = input.slug ?? slugifyName(input.name); + if (!baseSlug) { + throw new ConflictException('Unable to derive organization slug'); + } + + let slug = baseSlug; + for (let attempt = 0; attempt < 5; attempt += 1) { + const existing = await this.prisma.organization.findUnique({ + where: { slug }, + }); + if (!existing) { + break; + } + slug = `${baseSlug}-${randomUUID().slice(0, 6)}`; + } + + const organization = await this.prisma.$transaction(async (tx) => { + const created = await tx.organization.create({ + data: { + name: input.name.trim(), + slug, + }, + }); + + await tx.organizationMember.create({ + data: { + organizationId: created.id, + userId, + role: 'OWNER', + }, + }); + + return created; + }); + + return mapOrganization(organization, 'OWNER'); + } + + async listMembers( + userId: string, + organizationId: string, + ): Promise { + await this.requireMember(organizationId, userId); + + const members = await this.prisma.organizationMember.findMany({ + where: { organizationId }, + include: { + user: { + select: { + id: true, + email: true, + username: true, + displayName: true, + }, + }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return { + members: members.map((member) => ({ + id: member.id, + userId: member.userId, + role: member.role, + email: member.user.email, + username: member.user.username, + displayName: member.user.displayName, + createdAt: member.createdAt.toISOString(), + })), + }; + } + + async inviteMember( + userId: string, + organizationId: string, + input: InviteOrganizationMember, + ): Promise { + const membership = await this.requireMember(organizationId, userId); + if (!['OWNER', 'ADMIN'].includes(membership.role)) { + throw new ForbiddenException('Only organization admins can invite members'); + } + + const email = input.email.toLowerCase(); + const existingUser = await this.prisma.user.findUnique({ where: { email } }); + + if (existingUser) { + const member = await this.prisma.organizationMember.upsert({ + where: { + organizationId_userId: { + organizationId, + userId: existingUser.id, + }, + }, + create: { + organizationId, + userId: existingUser.id, + role: input.role, + }, + update: { role: input.role }, + }); + + return { + memberId: member.id, + userId: member.userId, + role: member.role, + }; + } + + const token = generateSecureToken(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + INVITE_TTL_DAYS); + + const invite = await this.prisma.organizationInvite.create({ + data: { + organizationId, + email, + role: input.role, + tokenHash: hashToken(token), + invitedById: userId, + expiresAt, + }, + }); + + return { + id: invite.id, + email: invite.email, + role: invite.role, + expiresAt: invite.expiresAt.toISOString(), + }; + } + + private async requireMember( + organizationId: string, + userId: string, + ): Promise { + const membership = await this.prisma.organizationMember.findUnique({ + where: { + organizationId_userId: { organizationId, userId }, + }, + }); + + if (!membership) { + throw new NotFoundException('Organization not found'); + } + + return membership; + } +} diff --git a/apps/api/src/servers/files/files.controller.ts b/apps/api/src/servers/files/files.controller.ts index be73e7e..0417e88 100644 --- a/apps/api/src/servers/files/files.controller.ts +++ b/apps/api/src/servers/files/files.controller.ts @@ -1,9 +1,13 @@ import { Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, + Post, Put, Query, UseGuards, @@ -11,9 +15,13 @@ import { import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { + archiveFilesSchema, + filePathSchema, listFilesQuerySchema, readFileContentQuerySchema, + unarchiveFileSchema, updateFileContentSchema, + uploadFileSchema, } from '@hexahost/contracts'; import { CurrentUser } from '../../auth/decorators/current-user.decorator'; @@ -30,7 +38,6 @@ export class FilesController { @Get() @ApiOperation({ summary: 'List files in the server data directory' }) - @ApiResponse({ status: 200, description: 'Directory listing' }) list( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, @@ -41,7 +48,6 @@ export class FilesController { @Get('content') @ApiOperation({ summary: 'Read a text file from the server' }) - @ApiResponse({ status: 200, description: 'File content' }) read( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, @@ -53,7 +59,6 @@ export class FilesController { @Put('content') @ApiOperation({ summary: 'Write a text file on the server' }) - @ApiResponse({ status: 200, description: 'Updated file content' }) write( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, @@ -65,4 +70,59 @@ export class FilesController { body as Parameters[2], ); } + + @Post('upload') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Upload a file (base64) to the server' }) + upload( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(uploadFileSchema)) body: unknown, + ) { + return this.filesService.uploadFile( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete() + @ApiOperation({ summary: 'Delete a file or directory' }) + delete( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Query(new ZodValidationPipe(filePathSchema)) query: { path: string }, + ) { + return this.filesService.deleteFile(user.id, serverId, query.path); + } + + @Post('archive') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Create a tar.gz archive from selected paths' }) + archive( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(archiveFilesSchema)) body: unknown, + ) { + return this.filesService.archiveFiles( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Post('unarchive') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Extract a tar.gz archive on the server' }) + unarchive( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(unarchiveFileSchema)) body: unknown, + ) { + return this.filesService.unarchiveFile( + user.id, + serverId, + body as Parameters[2], + ); + } } diff --git a/apps/api/src/servers/files/files.service.ts b/apps/api/src/servers/files/files.service.ts index 24532c2..e3a874c 100644 --- a/apps/api/src/servers/files/files.service.ts +++ b/apps/api/src/servers/files/files.service.ts @@ -5,9 +5,12 @@ import { } from '@nestjs/common'; import type { + ArchiveFiles, FileContent, FileListResponse, + UnarchiveFile, UpdateFileContent, + UploadFile, } from '@hexahost/contracts'; import type { GameServer } from '@hexahost/database'; @@ -19,6 +22,15 @@ import { assertSafeServerPath } from '../shared/server-path.util'; const FILE_ACCESS_STATUSES = new Set(['RUNNING', 'STOPPED']); +interface AgentFileEntry { + name: string; + path: string; + isDir?: boolean; + type?: 'file' | 'directory'; + size?: number; + modifiedAt?: string; +} + @Injectable() export class FilesService { constructor( @@ -51,7 +63,18 @@ export class FilesService { ); } - return response.result as FileListResponse; + const raw = response.result as { entries?: AgentFileEntry[] }; + const entries = (raw.entries ?? []).map((entry) => ({ + name: entry.name, + path: entry.path, + type: (entry.isDir ?? entry.type === 'directory' ? 'directory' : 'file') as + | 'file' + | 'directory', + size: entry.size, + modifiedAt: entry.modifiedAt, + })); + + return { path: safePath, entries }; } async readFile( @@ -78,7 +101,12 @@ export class FilesService { ); } - return response.result as FileContent; + const raw = response.result as { path: string; content: string }; + return { + path: raw.path, + content: raw.content, + encoding: 'utf-8', + }; } async writeFile( @@ -98,6 +126,7 @@ export class FilesService { path: safePath, content: input.content, encoding: input.encoding, + create: true, }, ); @@ -115,7 +144,140 @@ export class FilesService { metadata: { path: safePath }, }); - return response.result as FileContent; + return { + path: safePath, + content: input.content, + encoding: input.encoding ?? 'utf-8', + }; + } + + async uploadFile( + userId: string, + serverId: string, + input: UploadFile, + ): Promise { + const content = + input.encoding === 'base64' + ? Buffer.from(input.content, 'base64').toString('utf-8') + : input.content; + + return this.writeFile(userId, serverId, { + path: input.path, + content, + encoding: 'utf-8', + }); + } + + async deleteFile(userId: string, serverId: string, path: string): Promise<{ path: string }> { + const server = await this.requireFileAccess(userId, serverId); + const safePath = assertSafeServerPath(path); + + const response = await this.nodeBridge.sendAgentRequest( + server.nodeId!, + 'server.files.delete', + { + serverId, + generation: server.version, + path: safePath, + }, + ); + + if (!response.success) { + throw new ServiceUnavailableException( + response.errorMessage ?? 'Failed to delete file', + ); + } + + await this.auditService.record({ + action: 'server.file.delete', + userId, + entityType: 'game_server', + entityId: serverId, + metadata: { path: safePath }, + }); + + return { path: safePath }; + } + + async archiveFiles( + userId: string, + serverId: string, + input: ArchiveFiles, + ): Promise<{ archivePath: string; sha256?: string; size?: number }> { + const server = await this.requireFileAccess(userId, serverId); + const paths = input.paths.map((p) => assertSafeServerPath(p)); + + const response = await this.nodeBridge.sendAgentRequest( + server.nodeId!, + 'server.files.archive', + { + serverId, + generation: server.version, + paths, + archiveName: input.archiveName, + }, + 120_000, + ); + + if (!response.success) { + throw new ServiceUnavailableException( + response.errorMessage ?? 'Failed to create archive', + ); + } + + const result = response.result as { + archivePath: string; + sha256?: string; + size?: number; + }; + + await this.auditService.record({ + action: 'server.file.archive', + userId, + entityType: 'game_server', + entityId: serverId, + metadata: { paths, archivePath: result.archivePath }, + }); + + return result; + } + + async unarchiveFile( + userId: string, + serverId: string, + input: UnarchiveFile, + ): Promise<{ destinationPath: string }> { + const server = await this.requireFileAccess(userId, serverId); + const archivePath = assertSafeServerPath(input.archivePath); + const destinationPath = assertSafeServerPath(input.destinationPath); + + const response = await this.nodeBridge.sendAgentRequest( + server.nodeId!, + 'server.files.unarchive', + { + serverId, + generation: server.version, + archivePath, + destinationPath, + }, + 120_000, + ); + + if (!response.success) { + throw new ServiceUnavailableException( + response.errorMessage ?? 'Failed to extract archive', + ); + } + + await this.auditService.record({ + action: 'server.file.unarchive', + userId, + entityType: 'game_server', + entityId: serverId, + metadata: { archivePath, destinationPath }, + }); + + return { destinationPath }; } private async requireFileAccess( diff --git a/apps/api/src/servers/members/members.controller.ts b/apps/api/src/servers/members/members.controller.ts new file mode 100644 index 0000000..e0b37ab --- /dev/null +++ b/apps/api/src/servers/members/members.controller.ts @@ -0,0 +1,119 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { z } from 'zod'; + +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { PrismaService } from '../../prisma/prisma.service'; +import { findAccessibleServer } from '../shared/server-ownership.util'; + +const inviteSchema = z.object({ + email: z.string().email(), + role: z.enum(['ADMIN', 'OPERATOR', 'DEVELOPER', 'VIEWER']).default('VIEWER'), +}); + +@ApiTags('servers') +@Controller('servers/:serverId/members') +@UseGuards(SessionGuard) +export class MembersController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + @ApiOperation({ summary: 'List server members' }) + async list( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + ): Promise<{ + members: Array<{ + id: string; + userId: string; + role: string; + email: string; + username: string; + displayName: string | null; + acceptedAt: string | null; + }>; + }> { + await findAccessibleServer(this.prisma, user.id, serverId, 'server.view'); + + const members = await this.prisma.gameServerMember.findMany({ + where: { serverId }, + include: { + user: { select: { id: true, email: true, username: true, displayName: true } }, + }, + orderBy: { createdAt: 'asc' }, + }); + + return { + members: members.map((member) => ({ + id: member.id, + userId: member.userId, + role: member.role, + email: member.user.email, + username: member.user.username, + displayName: member.user.displayName, + acceptedAt: member.acceptedAt?.toISOString() ?? null, + })), + }; + } + + @Post() + @ApiOperation({ summary: 'Invite a user to the server' }) + async invite( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(inviteSchema)) body: unknown, + ): Promise<{ status?: string; message?: string; id?: string; userId?: string; role?: string }> { + await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage'); + const input = body as z.infer; + + const invitedUser = await this.prisma.user.findUnique({ + where: { email: input.email.toLowerCase() }, + }); + + if (!invitedUser) { + return { status: 'pending', message: 'User must register before accepting invite' }; + } + + const member = await this.prisma.gameServerMember.upsert({ + where: { + serverId_userId: { serverId, userId: invitedUser.id }, + }, + create: { + serverId, + userId: invitedUser.id, + role: input.role, + acceptedAt: new Date(), + }, + update: { role: input.role }, + }); + + return { id: member.id, userId: member.userId, role: member.role }; + } + + @Delete(':memberId') + @ApiOperation({ summary: 'Remove a server member' }) + async remove( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Param('memberId', ParseUUIDPipe) memberId: string, + ) { + await findAccessibleServer(this.prisma, user.id, serverId, 'server.members.manage'); + + await this.prisma.gameServerMember.deleteMany({ + where: { id: memberId, serverId }, + }); + + return { removed: true }; + } +} diff --git a/apps/api/src/servers/members/members.module.ts b/apps/api/src/servers/members/members.module.ts new file mode 100644 index 0000000..8f7047f --- /dev/null +++ b/apps/api/src/servers/members/members.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +import { MembersController } from './members.controller'; + +@Module({ + controllers: [MembersController], +}) +export class MembersModule {} diff --git a/apps/api/src/servers/players/players.controller.ts b/apps/api/src/servers/players/players.controller.ts index a5a9613..1b9537e 100644 --- a/apps/api/src/servers/players/players.controller.ts +++ b/apps/api/src/servers/players/players.controller.ts @@ -1,14 +1,27 @@ import { + Body, Controller, + Delete, Get, + HttpCode, + HttpStatus, Param, ParseUUIDPipe, + Post, UseGuards, } from '@nestjs/common'; -import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + addBanSchema, + addOperatorSchema, + addWhitelistSchema, + removePlayerEntrySchema, +} from '@hexahost/contracts'; import { CurrentUser } from '../../auth/decorators/current-user.decorator'; import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; import { PlayersService } from './players.service'; @@ -19,12 +32,105 @@ export class PlayersController { constructor(private readonly playersService: PlayersService) {} @Get() - @ApiOperation({ summary: 'List online players, whitelist, and operators' }) - @ApiResponse({ status: 200, description: 'Player overview' }) + @ApiOperation({ summary: 'List online players, whitelist, operators, and bans' }) list( @CurrentUser() user: { id: string }, @Param('serverId', ParseUUIDPipe) serverId: string, ) { return this.playersService.listPlayers(user.id, serverId); } + + @Post('whitelist') + addWhitelist( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addWhitelistSchema)) body: unknown, + ) { + return this.playersService.addWhitelist( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete('whitelist') + removeWhitelist( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown, + ) { + return this.playersService.removeWhitelist( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Post('operators') + addOperator( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addOperatorSchema)) body: unknown, + ) { + return this.playersService.addOperator( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete('operators') + removeOperator( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(removePlayerEntrySchema)) body: unknown, + ) { + return this.playersService.removeOperator( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Post('bans') + addBan( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addBanSchema)) body: unknown, + ) { + return this.playersService.addBan( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Delete('bans') + removeBan( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addBanSchema.pick({ name: true }))) body: unknown, + ) { + return this.playersService.removeBan( + user.id, + serverId, + body as { name: string }, + ); + } + + @Post('kick') + @HttpCode(HttpStatus.ACCEPTED) + kick( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(addBanSchema.pick({ name: true }).extend({ + reason: addBanSchema.shape.reason, + }))) body: unknown, + ) { + return this.playersService.kickPlayer( + user.id, + serverId, + body as { name: string; reason?: string }, + ); + } } diff --git a/apps/api/src/servers/players/players.service.ts b/apps/api/src/servers/players/players.service.ts index ecf64ab..8545d47 100644 --- a/apps/api/src/servers/players/players.service.ts +++ b/apps/api/src/servers/players/players.service.ts @@ -3,7 +3,13 @@ import { Injectable, } from '@nestjs/common'; -import type { PlayerListResponse } from '@hexahost/contracts'; +import type { + AddBan, + AddOperator, + AddWhitelist, + PlayerListResponse, + RemovePlayerEntry, +} from '@hexahost/contracts'; import type { GameServer } from '@hexahost/database'; import { NodeBridgeService } from '../../nodes/node-bridge.service'; @@ -13,6 +19,7 @@ import { findOwnedServer } from '../shared/server-ownership.util'; const WHITELIST_FILE = 'whitelist.json'; const OPS_FILE = 'ops.json'; +const BANS_FILE = 'banned-players.json'; @Injectable() export class PlayersService { @@ -32,17 +39,114 @@ export class PlayersService { throw new BadRequestException('Server is not assigned to a node'); } - const [online, whitelist, operators] = await Promise.all([ + const [online, whitelist, operators, bans] = await Promise.all([ this.fetchOnlinePlayers(server), this.readJsonFile(userId, serverId, WHITELIST_FILE, []), this.readJsonFile(userId, serverId, OPS_FILE, []), + this.readJsonFile(userId, serverId, BANS_FILE, []), ]); - return { - online, - whitelist, - operators, - }; + return { online, whitelist, operators, bans }; + } + + async addWhitelist(userId: string, serverId: string, input: AddWhitelist) { + const list = await this.readJsonFile>( + userId, + serverId, + WHITELIST_FILE, + [], + ); + const next = list.filter((e) => e.uuid !== input.uuid); + next.push({ uuid: input.uuid, name: input.name }); + await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next); + await this.runCommandIfRunning(userId, serverId, `whitelist add ${input.name}`); + return { whitelist: next }; + } + + async removeWhitelist(userId: string, serverId: string, input: RemovePlayerEntry) { + const list = await this.readJsonFile>( + userId, + serverId, + WHITELIST_FILE, + [], + ); + const entry = list.find((e) => e.uuid === input.uuid); + const next = list.filter((e) => e.uuid !== input.uuid); + await this.writeJsonFile(userId, serverId, WHITELIST_FILE, next); + if (entry) { + await this.runCommandIfRunning(userId, serverId, `whitelist remove ${entry.name}`); + } + return { whitelist: next }; + } + + async addOperator(userId: string, serverId: string, input: AddOperator) { + const list = await this.readJsonFile< + Array<{ uuid: string; name: string; level?: number; bypassesPlayerLimit?: boolean }> + >(userId, serverId, OPS_FILE, []); + const next = list.filter((e) => e.uuid !== input.uuid); + next.push({ + uuid: input.uuid, + name: input.name, + level: input.level, + bypassesPlayerLimit: input.bypassesPlayerLimit, + }); + await this.writeJsonFile(userId, serverId, OPS_FILE, next); + await this.runCommandIfRunning( + userId, + serverId, + `op ${input.name}`, + ); + return { operators: next }; + } + + async removeOperator(userId: string, serverId: string, input: RemovePlayerEntry) { + const list = await this.readJsonFile>( + userId, + serverId, + OPS_FILE, + [], + ); + const entry = list.find((e) => e.uuid === input.uuid); + const next = list.filter((e) => e.uuid !== input.uuid); + await this.writeJsonFile(userId, serverId, OPS_FILE, next); + if (entry) { + await this.runCommandIfRunning(userId, serverId, `deop ${entry.name}`); + } + return { operators: next }; + } + + async addBan(userId: string, serverId: string, input: AddBan) { + const list = await this.readJsonFile< + Array<{ uuid?: string; name: string; reason?: string; source?: string }> + >(userId, serverId, BANS_FILE, []); + const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase()); + next.push({ + name: input.name, + reason: input.reason ?? 'Banned by panel', + source: 'HexaHost Panel', + }); + await this.writeJsonFile(userId, serverId, BANS_FILE, next); + await this.runCommandIfRunning(userId, serverId, `ban ${input.name}`); + return { bans: next }; + } + + async removeBan(userId: string, serverId: string, input: { name: string }) { + const list = await this.readJsonFile>( + userId, + serverId, + BANS_FILE, + [], + ); + const next = list.filter((e) => e.name.toLowerCase() !== input.name.toLowerCase()); + await this.writeJsonFile(userId, serverId, BANS_FILE, next); + await this.runCommandIfRunning(userId, serverId, `pardon ${input.name}`); + return { bans: next }; + } + + async kickPlayer(userId: string, serverId: string, input: { name: string; reason?: string }) { + const reason = input.reason ? ` ${input.reason}` : ''; + await this.runCommandIfRunning(userId, serverId, `kick ${input.name}${reason}`); + return { kicked: input.name }; } private async fetchOnlinePlayers( @@ -106,4 +210,39 @@ export class PlayersService { return fallback; } } + + private async writeJsonFile( + userId: string, + serverId: string, + path: string, + data: T, + ): Promise { + await this.filesService.writeFile(userId, serverId, { + path, + content: `${JSON.stringify(data, null, 2)}\n`, + encoding: 'utf-8', + }); + } + + private async runCommandIfRunning( + userId: string, + serverId: string, + command: string, + ): Promise { + const server = await findOwnedServer(this.prisma, userId, serverId); + if (server.status !== 'RUNNING' || !server.nodeId) { + return; + } + + await this.nodeBridge.sendAgentRequest( + server.nodeId, + 'server.command', + { + serverId, + generation: server.version, + command, + }, + 10_000, + ); + } } diff --git a/apps/api/src/servers/schedules/schedules.controller.ts b/apps/api/src/servers/schedules/schedules.controller.ts new file mode 100644 index 0000000..3da1c1e --- /dev/null +++ b/apps/api/src/servers/schedules/schedules.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { createScheduleSchema, updateScheduleSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; + +import { SchedulesService } from './schedules.service'; + +@ApiTags('servers') +@Controller('servers/:serverId/schedules') +@UseGuards(SessionGuard) +export class SchedulesController { + constructor(private readonly schedulesService: SchedulesService) {} + + @Get() + @ApiOperation({ summary: 'List server schedules' }) + list( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + ) { + return this.schedulesService.list(user.id, serverId); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + create( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(createScheduleSchema)) body: unknown, + ) { + return this.schedulesService.create( + user.id, + serverId, + body as Parameters[2], + ); + } + + @Patch(':scheduleId') + update( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + @Body(new ZodValidationPipe(updateScheduleSchema)) body: unknown, + ) { + return this.schedulesService.update( + user.id, + serverId, + scheduleId, + body as Parameters[3], + ); + } + + @Delete(':scheduleId') + @HttpCode(HttpStatus.NO_CONTENT) + async remove( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Param('scheduleId', ParseUUIDPipe) scheduleId: string, + ) { + await this.schedulesService.remove(user.id, serverId, scheduleId); + } +} diff --git a/apps/api/src/servers/schedules/schedules.module.ts b/apps/api/src/servers/schedules/schedules.module.ts new file mode 100644 index 0000000..4ba589b --- /dev/null +++ b/apps/api/src/servers/schedules/schedules.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../../auth/auth.module'; +import { PrismaModule } from '../../prisma/prisma.module'; + +import { SchedulesController } from './schedules.controller'; +import { SchedulesService } from './schedules.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [SchedulesController], + providers: [SchedulesService], + exports: [SchedulesService], +}) +export class SchedulesModule {} diff --git a/apps/api/src/servers/schedules/schedules.service.ts b/apps/api/src/servers/schedules/schedules.service.ts new file mode 100644 index 0000000..20faf34 --- /dev/null +++ b/apps/api/src/servers/schedules/schedules.service.ts @@ -0,0 +1,150 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CronExpressionParser } from 'cron-parser'; + +import type { + CreateSchedule, + Schedule as ScheduleDto, + ScheduleListResponse, + UpdateSchedule, +} from '@hexahost/contracts'; +import type { Prisma, Schedule, ScheduledTask } from '@hexahost/database'; + +import { PrismaService } from '../../prisma/prisma.service'; +import { findOwnedServer } from '../shared/server-ownership.util'; + +function mapSchedule( + schedule: Schedule & { tasks: ScheduledTask[] }, +): ScheduleDto { + return { + id: schedule.id, + name: schedule.name, + timezone: schedule.timezone, + cronExpr: schedule.cronExpr, + enabled: schedule.enabled, + nextRunAt: schedule.nextRunAt?.toISOString() ?? null, + lastRunAt: schedule.lastRunAt?.toISOString() ?? null, + tasks: schedule.tasks.map((task) => ({ + id: task.id, + taskType: task.taskType, + payload: (task.payload as Record | null) ?? null, + })), + }; +} + +@Injectable() +export class SchedulesService { + constructor(private readonly prisma: PrismaService) {} + + async list(userId: string, serverId: string): Promise { + await findOwnedServer(this.prisma, userId, serverId); + const schedules = await this.prisma.schedule.findMany({ + where: { serverId }, + include: { tasks: true }, + orderBy: { createdAt: 'asc' }, + }); + return { schedules: schedules.map(mapSchedule) }; + } + + async create(userId: string, serverId: string, input: CreateSchedule) { + await findOwnedServer(this.prisma, userId, serverId); + const schedule = await this.prisma.schedule.create({ + data: { + serverId, + name: input.name, + timezone: input.timezone, + cronExpr: input.cronExpr, + enabled: input.enabled, + nextRunAt: this.computeNextRun(input.cronExpr, input.timezone), + tasks: { + create: input.tasks.map((task) => ({ + taskType: task.taskType, + payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined, + })), + }, + }, + include: { tasks: true }, + }); + return mapSchedule(schedule); + } + + async update( + userId: string, + serverId: string, + scheduleId: string, + input: UpdateSchedule, + ) { + await this.requireSchedule(userId, serverId, scheduleId); + const existing = await this.prisma.schedule.findUniqueOrThrow({ + where: { id: scheduleId }, + }); + + await this.prisma.$transaction(async (tx) => { + if (input.tasks) { + await tx.scheduledTask.deleteMany({ where: { scheduleId } }); + await tx.scheduledTask.createMany({ + data: input.tasks.map((task) => ({ + scheduleId, + taskType: task.taskType, + payload: (task.payload ?? undefined) as Prisma.InputJsonValue | undefined, + })), + }); + } + + await tx.schedule.update({ + where: { id: scheduleId }, + data: { + name: input.name, + timezone: input.timezone, + cronExpr: input.cronExpr, + enabled: input.enabled, + nextRunAt: + input.cronExpr !== undefined || input.timezone !== undefined + ? this.computeNextRun( + input.cronExpr ?? existing.cronExpr, + input.timezone ?? existing.timezone, + ) + : undefined, + }, + }); + }); + + const schedule = await this.prisma.schedule.findUniqueOrThrow({ + where: { id: scheduleId }, + include: { tasks: true }, + }); + return mapSchedule(schedule); + } + + async remove(userId: string, serverId: string, scheduleId: string) { + await this.requireSchedule(userId, serverId, scheduleId); + await this.prisma.schedule.delete({ where: { id: scheduleId } }); + return { deleted: true }; + } + + private async requireSchedule(userId: string, serverId: string, scheduleId: string) { + await findOwnedServer(this.prisma, userId, serverId); + const schedule = await this.prisma.schedule.findFirst({ + where: { id: scheduleId, serverId }, + }); + if (!schedule) { + throw new NotFoundException('Schedule not found'); + } + return schedule; + } + + private computeNextRun(cronExpr: string, timezone = 'UTC'): Date { + try { + const interval = CronExpressionParser.parse(cronExpr, { + tz: timezone, + currentDate: new Date(), + }); + return interval.next().toDate(); + } catch { + throw new BadRequestException('Invalid cron expression or timezone'); + } + } +} diff --git a/apps/api/src/servers/server-state.service.ts b/apps/api/src/servers/server-state.service.ts index 0c6726e..f3e111f 100644 --- a/apps/api/src/servers/server-state.service.ts +++ b/apps/api/src/servers/server-state.service.ts @@ -1,34 +1,15 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import type { GameServer } from '@hexahost/database'; - -type GameServerStatus = GameServer['status']; - -const ALLOWED_TRANSITIONS: Record = { - DRAFT: ['PROVISIONING', 'DELETING'], - PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'], - INSTALLING: ['STOPPED', 'ERROR', 'DELETING'], - STOPPED: ['STARTING', 'QUEUED', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'], - QUEUED: ['STARTING', 'STOPPED', 'DELETING'], - STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], - RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'UNKNOWN', 'DELETING'], - STOPPING: ['STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], - BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'], - RESTORING: ['STOPPED', 'ERROR'], - UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'], - ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'], - DELETING: ['DELETED'], - DELETED: [], -}; - -const IN_PROGRESS_STATUSES: GameServerStatus[] = [ - 'PROVISIONING', - 'INSTALLING', - 'STARTING', - 'STOPPING', - 'BACKING_UP', - 'RESTORING', - 'DELETING', -]; +import { + ALLOWED_TRANSITIONS, + IN_PROGRESS_STATUSES, + assertTransition, + isInProgress, + resolveKillTarget, + resolveStartTarget, + resolveStopTarget, + type GameServerStatus, +} from '@hexahost/server-state'; @Injectable() export class ServerStateService { @@ -36,44 +17,56 @@ export class ServerStateService { fromStatus: GameServerStatus, toStatus: GameServerStatus, ): void { - const allowed = ALLOWED_TRANSITIONS[fromStatus]; - - if (!allowed.includes(toStatus)) { + try { + assertTransition(fromStatus, toStatus); + } catch (error) { throw new BadRequestException( - `Invalid state transition from ${fromStatus} to ${toStatus}`, + error instanceof Error ? error.message : 'Invalid state transition', ); } } assertNotInProgress(status: GameServerStatus): void { - if (IN_PROGRESS_STATUSES.includes(status)) { + if (isInProgress(status)) { throw new BadRequestException('Server operation already in progress'); } } resolveStartTarget(status: GameServerStatus): GameServerStatus { - switch (status) { - case 'DRAFT': - case 'ERROR': - return 'PROVISIONING'; - case 'STOPPED': - case 'UNKNOWN': - case 'QUEUED': - return 'STARTING'; - default: - throw new BadRequestException( - `Cannot start server while in status ${status}`, - ); + try { + return resolveStartTarget(status); + } catch (error) { + throw new BadRequestException( + error instanceof Error ? error.message : 'Cannot start server', + ); } } resolveStopTarget(status: GameServerStatus): GameServerStatus { - if (status !== 'RUNNING') { + try { + return resolveStopTarget(status); + } catch (error) { throw new BadRequestException( - `Cannot stop server while in status ${status}`, + error instanceof Error ? error.message : 'Cannot stop server', ); } + } - return 'STOPPING'; + resolveKillTarget(status: GameServerStatus): GameServerStatus { + try { + return resolveKillTarget(status); + } catch (error) { + throw new BadRequestException( + error instanceof Error ? error.message : 'Cannot kill server', + ); + } + } + + allowedTransitions(): typeof ALLOWED_TRANSITIONS { + return ALLOWED_TRANSITIONS; + } + + inProgressStatuses(): GameServerStatus[] { + return IN_PROGRESS_STATUSES; } } diff --git a/apps/api/src/servers/servers.controller.ts b/apps/api/src/servers/servers.controller.ts index e88992f..29baf98 100644 --- a/apps/api/src/servers/servers.controller.ts +++ b/apps/api/src/servers/servers.controller.ts @@ -1,18 +1,20 @@ import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, + Patch, Post, Query, UseGuards, } from '@nestjs/common'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; -import { createServerRequestSchema } from '@hexahost/contracts'; +import { createServerRequestSchema, updateServerRequestSchema } from '@hexahost/contracts'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { SessionGuard } from '../auth/guards/session.guard'; @@ -90,6 +92,40 @@ export class ServersController { return this.serversService.restartServer(user.id, id, skip === 'true'); } + @Post(':id/kill') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Force-kill a running game server' }) + kill( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.serversService.killServer(user.id, id); + } + + @Delete(':id') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Delete a game server' }) + delete( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + ) { + return this.serversService.deleteServer(user.id, id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update server metadata' }) + patch( + @CurrentUser() user: { id: string }, + @Param('id', ParseUUIDPipe) id: string, + @Body(new ZodValidationPipe(updateServerRequestSchema)) body: unknown, + ) { + return this.serversService.updateServer( + user.id, + id, + body as Parameters[2], + ); + } + @Get(':id/start-queue') @ApiOperation({ summary: 'Get start queue status for a server' }) startQueue( diff --git a/apps/api/src/servers/servers.module.ts b/apps/api/src/servers/servers.module.ts index aa04290..b3f8477 100644 --- a/apps/api/src/servers/servers.module.ts +++ b/apps/api/src/servers/servers.module.ts @@ -9,8 +9,12 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module'; import { AppConfigModule } from '../config/app-config.module'; import { BillingModule } from '../billing/billing.module'; +import { FeatureFlagsModule } from '../feature-flags/feature-flags.module'; import { SchedulingModule } from '../scheduling/scheduling.module'; +import { MembersModule } from './members/members.module'; +import { SchedulesModule } from './schedules/schedules.module'; +import { SoftwareModule } from './software/software.module'; import { ConsoleModule } from './console/console.module'; import { AddonsModule } from './addons/addons.module'; import { BackupsModule } from './backups/backups.module'; @@ -34,6 +38,7 @@ import { PlansService } from './plans.service'; imports: [ AppConfigModule, AuthModule, + FeatureFlagsModule, SchedulingModule, BillingModule, IdleModule, @@ -44,6 +49,9 @@ import { PlansService } from './plans.service'; forwardRef(() => FilesModule), forwardRef(() => PropertiesModule), forwardRef(() => PlayersModule), + forwardRef(() => MembersModule), + forwardRef(() => SchedulesModule), + forwardRef(() => SoftwareModule), forwardRef(() => NodeBridgeModule), ], controllers: [ServersController, PlansController], @@ -88,6 +96,7 @@ import { PlansService } from './plans.service'; exports: [ ServersService, REDIS_CLIENT, + SERVER_LIFECYCLE_QUEUE, ConsoleModule, NodeBridgeModule, ], diff --git a/apps/api/src/servers/servers.service.ts b/apps/api/src/servers/servers.service.ts index 8fa79ca..5c0e249 100644 --- a/apps/api/src/servers/servers.service.ts +++ b/apps/api/src/servers/servers.service.ts @@ -5,6 +5,7 @@ import { ForbiddenException, Inject, Injectable, + ServiceUnavailableException, } from '@nestjs/common'; import { Queue } from 'bullmq'; import type Redis from 'ioredis'; @@ -16,10 +17,12 @@ import type { ServerResponse, } from '@hexahost/contracts'; import type { GameServer } from '@hexahost/database'; +import { FeatureFlagService } from '@hexahost/feature-flags'; import { formatJoinAddress } from '@hexahost/dns'; type GameServerStatus = GameServer['status']; +import { FEATURE_FLAG_SERVICE } from '../feature-flags/feature-flags.module'; import { PrismaService } from '../prisma/prisma.service'; import { BillingService } from '../billing/billing.service'; import { SchedulingService } from '../scheduling/scheduling.service'; @@ -45,12 +48,14 @@ export class ServersService { private readonly lifecycleQueue: Queue, @Inject(SERVER_PROVISIONING_QUEUE) private readonly provisioningQueue: Queue, + @Inject(FEATURE_FLAG_SERVICE) private readonly featureFlags: FeatureFlagService, ) {} async createServer( userId: string, input: CreateServerRequest, ): Promise { + await this.assertMaintenanceAllowed('create'); await this.billing.assertCanCreateServer(userId, input.planId); const server = await this.prisma.gameServer.create({ @@ -108,6 +113,7 @@ export class ServersService { userId: string, serverId: string, ): Promise { + await this.assertMaintenanceAllowed('start'); const server = await findOwnedServer(this.prisma, userId, serverId); this.assertServerNotBillingSuspended(server); this.serverState.assertNotInProgress(server.status); @@ -323,6 +329,180 @@ export class ServersService { }; } + async killServer( + userId: string, + serverId: string, + ): Promise { + const server = await findOwnedServer(this.prisma, userId, serverId); + this.assertServerNotBillingSuspended(server); + + const targetStatus = this.serverState.resolveKillTarget(server.status); + this.serverState.assertTransition(server.status, targetStatus); + + const correlationId = randomUUID(); + const updated = await this.transitionServer( + server, + targetStatus, + 'kill requested', + userId, + correlationId, + ); + + const job = await this.lifecycleQueue.add( + 'kill-server', + { serverId, action: 'kill', correlationId }, + { jobId: correlationId }, + ); + + if (updated.nodeId) { + await this.publishNodeCommand(updated.nodeId, { + protocolVersion: 1, + messageId: randomUUID(), + correlationId, + type: 'server.kill', + timestamp: new Date().toISOString(), + payload: { + serverId, + generation: updated.version, + }, + }); + } + + return { + server: this.toResponse(updated), + jobId: job.id, + }; + } + + async deleteServer( + userId: string, + serverId: string, + ): Promise { + const server = await findOwnedServer(this.prisma, userId, serverId); + this.serverState.assertNotInProgress(server.status); + + if (server.status === 'RUNNING' || server.status === 'STARTING') { + throw new ConflictException('Stop the server before deleting it'); + } + + this.serverState.assertTransition(server.status, 'DELETING'); + + const correlationId = randomUUID(); + const updated = await this.transitionServer( + server, + 'DELETING', + 'delete requested', + userId, + correlationId, + ); + + const job = await this.lifecycleQueue.add( + 'delete-server', + { serverId, action: 'delete', correlationId }, + { jobId: correlationId }, + ); + + if (updated.nodeId) { + await this.publishNodeCommand(updated.nodeId, { + protocolVersion: 1, + messageId: randomUUID(), + correlationId, + type: 'server.delete', + timestamp: new Date().toISOString(), + payload: { + serverId, + generation: updated.version, + }, + }); + } + + return { + server: this.toResponse(updated), + jobId: job.id, + }; + } + + async updateServer( + userId: string, + serverId: string, + input: { name?: string; idleShutdownEnabled?: boolean }, + ): Promise { + await findOwnedServer(this.prisma, userId, serverId); + + const updated = await this.prisma.gameServer.update({ + where: { id: serverId }, + data: { + ...(input.name !== undefined ? { name: input.name } : {}), + ...(input.idleShutdownEnabled !== undefined + ? { idleShutdownEnabled: input.idleShutdownEnabled } + : {}), + }, + }); + + return this.toResponse(updated); + } + + async reinstallServer( + userId: string, + serverId: string, + input: { + softwareFamily: string; + minecraftVersion: string; + createBackup?: boolean; + }, + ): Promise<{ serverId: string; jobId: string; status: string }> { + const server = await findOwnedServer(this.prisma, userId, serverId); + this.serverState.assertNotInProgress(server.status); + + if (input.createBackup) { + await this.lifecycleQueue.add('backup-server', { + serverId, + type: 'MANUAL', + reason: 'pre-reinstall', + }); + } + + const correlationId = randomUUID(); + const updated = await this.prisma.gameServer.update({ + where: { id: serverId }, + data: { + softwareFamily: input.softwareFamily as GameServer['softwareFamily'], + minecraftVersion: input.minecraftVersion, + status: 'PROVISIONING', + version: { increment: 1 }, + }, + }); + + await this.prisma.gameServerStateTransition.create({ + data: { + gameServerId: serverId, + fromStatus: server.status, + toStatus: 'PROVISIONING', + reason: 'Software reinstall requested', + actorId: userId, + correlationId, + }, + }); + + const job = await this.provisioningQueue.add( + 'provision-server', + { + serverId, + correlationId, + reinstall: true, + softwareFamily: input.softwareFamily, + minecraftVersion: input.minecraftVersion, + }, + { jobId: correlationId }, + ); + + return { + serverId, + jobId: job.id ?? correlationId, + status: updated.status, + }; + } + async publishNodeCommand( nodeId: string, envelope: Record, @@ -422,6 +602,15 @@ export class ServersService { } } + private async assertMaintenanceAllowed(action: string): Promise { + const maintenance = await this.featureFlags.isEnabled('maintenance_mode'); + if (maintenance) { + throw new ServiceUnavailableException( + `Platform maintenance mode is active; cannot ${action} servers`, + ); + } + } + private async recordTransition( gameServerId: string, fromStatus: GameServerStatus | null, diff --git a/apps/api/src/servers/shared/server-ownership.util.ts b/apps/api/src/servers/shared/server-ownership.util.ts index cf790a5..4aea3aa 100644 --- a/apps/api/src/servers/shared/server-ownership.util.ts +++ b/apps/api/src/servers/shared/server-ownership.util.ts @@ -3,25 +3,75 @@ import { NotFoundException, } from '@nestjs/common'; import type { GameServer } from '@hexahost/database'; +import { + assertPermission, + type ServerMemberRole, + type ServerPermission, +} from '@hexahost/permissions'; import type { PrismaService } from '../../prisma/prisma.service'; +export interface ServerAccessContext { + server: GameServer; + role: ServerMemberRole; + permissionOverrides: ServerPermission[] | null; +} + export async function findOwnedServer( prisma: PrismaService, userId: string, serverId: string, ): Promise { + const access = await findAccessibleServer(prisma, userId, serverId, 'server.view'); + return access.server; +} + +export async function findAccessibleServer( + prisma: PrismaService, + userId: string, + serverId: string, + permission: ServerPermission, +): Promise { const server = await prisma.gameServer.findUnique({ where: { id: serverId }, + include: { + members: { + where: { userId }, + }, + }, }); if (!server) { throw new NotFoundException('Server not found'); } - if (server.userId !== userId) { - throw new ForbiddenException('You do not own this server'); + let role: ServerMemberRole; + let overrides: ServerPermission[] | null = null; + + if (server.userId === userId) { + role = 'OWNER'; + } else { + const membership = server.members[0]; + + if (!membership) { + throw new ForbiddenException('You do not have access to this server'); + } + + role = membership.role as ServerMemberRole; + overrides = Array.isArray(membership.permissions) + ? (membership.permissions as ServerPermission[]) + : null; } - return server; + try { + assertPermission(role, permission, overrides); + } catch { + throw new ForbiddenException(`Missing permission: ${permission}`); + } + + return { + server, + role, + permissionOverrides: overrides, + }; } diff --git a/apps/api/src/servers/software/software.controller.ts b/apps/api/src/servers/software/software.controller.ts new file mode 100644 index 0000000..85de1fc --- /dev/null +++ b/apps/api/src/servers/software/software.controller.ts @@ -0,0 +1,40 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { reinstallServerSchema } from '@hexahost/contracts'; + +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../../auth/guards/session.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { ServersService } from '../servers.service'; + +@ApiTags('servers') +@Controller('servers/:serverId/software') +@UseGuards(SessionGuard) +export class SoftwareController { + constructor(private readonly serversService: ServersService) {} + + @Post('reinstall') + @HttpCode(HttpStatus.ACCEPTED) + @ApiOperation({ summary: 'Reinstall server software' }) + reinstall( + @CurrentUser() user: { id: string }, + @Param('serverId', ParseUUIDPipe) serverId: string, + @Body(new ZodValidationPipe(reinstallServerSchema)) body: unknown, + ) { + return this.serversService.reinstallServer( + user.id, + serverId, + body as Parameters[2], + ); + } +} diff --git a/apps/api/src/servers/software/software.module.ts b/apps/api/src/servers/software/software.module.ts new file mode 100644 index 0000000..abceaf8 --- /dev/null +++ b/apps/api/src/servers/software/software.module.ts @@ -0,0 +1,12 @@ +import { forwardRef, Module } from '@nestjs/common'; + +import { AuthModule } from '../../auth/auth.module'; +import { ServersModule } from '../servers.module'; + +import { SoftwareController } from './software.controller'; + +@Module({ + imports: [AuthModule, forwardRef(() => ServersModule)], + controllers: [SoftwareController], +}) +export class SoftwareModule {} diff --git a/apps/api/src/support/support.controller.ts b/apps/api/src/support/support.controller.ts new file mode 100644 index 0000000..635fee8 --- /dev/null +++ b/apps/api/src/support/support.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { + addSupportTicketMessageSchema, + createSupportTicketSchema, +} from '@hexahost/contracts'; + +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { SessionGuard } from '../auth/guards/session.guard'; +import { ZodValidationPipe } from '../common/pipes/zod-validation.pipe'; + +import { SupportService } from './support.service'; + +@ApiTags('support') +@Controller('support/tickets') +@UseGuards(SessionGuard) +export class SupportController { + constructor(private readonly supportService: SupportService) {} + + @Post() + @ApiOperation({ summary: 'Create a support ticket' }) + create( + @CurrentUser() user: { id: string }, + @Body(new ZodValidationPipe(createSupportTicketSchema)) body: unknown, + ) { + return this.supportService.create( + user.id, + body as Parameters[1], + ); + } + + @Get() + @ApiOperation({ summary: "List current user's support tickets" }) + list(@CurrentUser() user: { id: string }) { + return this.supportService.list(user.id); + } + + @Get(':ticketId') + @ApiOperation({ summary: 'Get a support ticket with messages' }) + get( + @CurrentUser() user: { id: string }, + @Param('ticketId', ParseUUIDPipe) ticketId: string, + ) { + return this.supportService.get(user.id, ticketId); + } + + @Post(':ticketId/messages') + @ApiOperation({ summary: 'Add a message to a support ticket' }) + addMessage( + @CurrentUser() user: { id: string }, + @Param('ticketId', ParseUUIDPipe) ticketId: string, + @Body(new ZodValidationPipe(addSupportTicketMessageSchema)) body: unknown, + ) { + return this.supportService.addMessage( + user.id, + ticketId, + body as Parameters[2], + ); + } +} diff --git a/apps/api/src/support/support.module.ts b/apps/api/src/support/support.module.ts new file mode 100644 index 0000000..11cff6a --- /dev/null +++ b/apps/api/src/support/support.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; + +import { SupportController } from './support.controller'; +import { SupportService } from './support.service'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [SupportController], + providers: [SupportService], + exports: [SupportService], +}) +export class SupportModule {} diff --git a/apps/api/src/support/support.service.ts b/apps/api/src/support/support.service.ts new file mode 100644 index 0000000..0979b68 --- /dev/null +++ b/apps/api/src/support/support.service.ts @@ -0,0 +1,143 @@ +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; + +import type { + AddSupportTicketMessage, + CreateSupportTicket, + SupportTicket, + SupportTicketListResponse, +} from '@hexahost/contracts'; +import type { + SupportTicket as SupportTicketRow, + SupportTicketMessage, +} from '@hexahost/database'; + +import { PrismaService } from '../prisma/prisma.service'; + +function mapTicket( + ticket: SupportTicketRow & { messages?: SupportTicketMessage[] }, + includeMessages = false, +): SupportTicket { + return { + id: ticket.id, + subject: ticket.subject, + status: ticket.status, + priority: ticket.priority, + serverId: ticket.serverId, + createdAt: ticket.createdAt.toISOString(), + updatedAt: ticket.updatedAt.toISOString(), + ...(includeMessages && ticket.messages + ? { + messages: ticket.messages.map((message) => ({ + id: message.id, + authorId: message.authorId, + isInternal: message.isInternal, + body: message.body, + createdAt: message.createdAt.toISOString(), + })), + } + : {}), + }; +} + +@Injectable() +export class SupportService { + constructor(private readonly prisma: PrismaService) {} + + async create(userId: string, input: CreateSupportTicket): Promise { + if (input.serverId) { + const server = await this.prisma.gameServer.findFirst({ + where: { id: input.serverId, userId }, + }); + if (!server) { + throw new ForbiddenException('Server not found or not owned by user'); + } + } + + const ticket = await this.prisma.supportTicket.create({ + data: { + userId, + subject: input.subject.trim(), + priority: input.priority, + serverId: input.serverId, + messages: { + create: { + authorId: userId, + body: input.body.trim(), + }, + }, + }, + include: { messages: true }, + }); + + return mapTicket(ticket, true); + } + + async list(userId: string): Promise { + const tickets = await this.prisma.supportTicket.findMany({ + where: { userId }, + orderBy: { updatedAt: 'desc' }, + take: 100, + }); + + return { tickets: tickets.map((ticket) => mapTicket(ticket)) }; + } + + async get(userId: string, ticketId: string): Promise { + const ticket = await this.prisma.supportTicket.findFirst({ + where: { id: ticketId, userId }, + include: { + messages: { + where: { isInternal: false }, + orderBy: { createdAt: 'asc' }, + }, + }, + }); + + if (!ticket) { + throw new NotFoundException('Support ticket not found'); + } + + return mapTicket(ticket, true); + } + + async addMessage( + userId: string, + ticketId: string, + input: AddSupportTicketMessage, + ): Promise { + const ticket = await this.prisma.supportTicket.findFirst({ + where: { id: ticketId, userId }, + }); + + if (!ticket) { + throw new NotFoundException('Support ticket not found'); + } + + if (ticket.status === 'CLOSED' || ticket.status === 'RESOLVED') { + throw new ForbiddenException('Cannot add messages to a closed ticket'); + } + + await this.prisma.$transaction([ + this.prisma.supportTicketMessage.create({ + data: { + ticketId, + authorId: userId, + body: input.body.trim(), + }, + }), + this.prisma.supportTicket.update({ + where: { id: ticketId }, + data: { + status: ticket.status === 'WAITING' ? 'OPEN' : ticket.status, + updatedAt: new Date(), + }, + }), + ]); + + return this.get(userId, ticketId); + } +} diff --git a/apps/docs/README.md b/apps/docs/README.md new file mode 100644 index 0000000..ec2fc93 --- /dev/null +++ b/apps/docs/README.md @@ -0,0 +1,28 @@ +# HexaHost GameCloud — static docs site + +Minimal static documentation hub with links to the live API docs and key product pages. + +## Serve locally + +From the repository root: + +```bash +pnpm install +pnpm --filter @hexahost/docs dev +``` + +Or from this directory: + +```bash +pnpm install +pnpm dev +``` + +The site is served at http://localhost:4000. + +## Prerequisites for linked resources + +- **Web app** (`http://localhost:3000`) — run `pnpm --filter @hexahost/web dev` +- **API Swagger** (`http://localhost:3001/api/v1/docs`) — run `pnpm --filter @hexahost/api dev` + +Repository markdown under `docs/` is referenced by path; open those files in your editor or on GitHub. diff --git a/apps/docs/index.html b/apps/docs/index.html new file mode 100644 index 0000000..5dcaef1 --- /dev/null +++ b/apps/docs/index.html @@ -0,0 +1,130 @@ + + + + + + HexaHost GameCloud Documentation + + + +
+

HexaHost GameCloud

+

Developer documentation hub for the GameCloud control plane.

+ +
+

API

+ +
+ +
+

Key features

+ +
+ +
+

Repository docs

+
    +
  • docs/api/README.md — API authentication and versioning
  • +
  • docs/operations/ — backups, upgrades, node drain, disaster recovery
  • +
  • docs/integrations/whmcs/ — WHMCS module installation and security
  • +
  • docs/security/ — container isolation, secrets, data retention
  • +
+
+
+ + diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 0000000..5b301bc --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,14 @@ +{ + "name": "@hexahost/docs", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "serve . -p 4000", + "start": "serve . -p 4000", + "lint": "node -e \"process.exit(0)\"", + "typecheck": "node -e \"process.exit(0)\"" + }, + "devDependencies": { + "serve": "^14.2.4" + } +} diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..fbc605c --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hexahost/e2e", + "version": "0.0.1", + "private": true, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "lint": "tsc --noEmit", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@hexahost/typescript-config": "workspace:*", + "@playwright/test": "^1.51.1", + "@types/node": "^22.13.10", + "typescript": "^5.7.3" + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 0000000..e3bb539 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: appUrl, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/apps/e2e/tests/smoke.spec.ts b/apps/e2e/tests/smoke.spec.ts new file mode 100644 index 0000000..c19de82 --- /dev/null +++ b/apps/e2e/tests/smoke.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; + +test("home page loads", async ({ page }) => { + const response = await page.goto("/"); + expect(response?.ok()).toBeTruthy(); + await expect(page.locator("h1")).toBeVisible(); +}); + +test("login page loads", async ({ page }) => { + const response = await page.goto("/login"); + expect(response?.ok()).toBeTruthy(); + await expect(page.getByRole("heading", { name: /Anmelden|Sign in|Log in/i })).toBeVisible(); + await expect(page.getByLabel(/E-Mail|Email/i)).toBeVisible(); + await expect(page.getByLabel(/^Passwort$|^Password$/i)).toBeVisible(); +}); + +test("register page loads", async ({ page }) => { + const response = await page.goto("/register"); + expect(response?.ok()).toBeTruthy(); + await expect( + page.getByRole("heading", { name: /Konto erstellen|Create account/i }), + ).toBeVisible(); + await expect(page.getByLabel(/E-Mail|Email/i)).toBeVisible(); + await expect(page.getByLabel(/^Passwort$|^Password$/i)).toBeVisible(); +}); + +test("docs page loads", async ({ page }) => { + const response = await page.goto("/docs"); + expect(response?.ok()).toBeTruthy(); + await expect( + page.getByRole("heading", { name: /Dokumentation|Documentation/i }), + ).toBeVisible(); +}); + +test("API health if reachable", async ({ request }) => { + const apiUrl = process.env.API_URL ?? "http://localhost:3001"; + + let response; + try { + response = await request.get(`${apiUrl}/api/v1/health/live`, { timeout: 3000 }); + } catch { + test.info().annotations.push({ + type: "skip-reason", + description: "API not reachable", + }); + return; + } + + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { status?: string }; + expect(body.status).toBe("ok"); +}); diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 0000000..5bf6e2c --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@hexahost/typescript-config/base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "types": ["node", "@playwright/test"] + }, + "include": ["**/*.ts"] +} diff --git a/apps/node-agent/internal/archive/archive.go b/apps/node-agent/internal/archive/archive.go index 1da0b27..a46bdb0 100644 --- a/apps/node-agent/internal/archive/archive.go +++ b/apps/node-agent/internal/archive/archive.go @@ -95,6 +95,99 @@ func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, er return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil } +// CreateTarGzFromPaths archives one or more paths under dataRoot into outputPath. +func CreateTarGzFromPaths(dataRoot string, relativePaths []string, outputPath string) (sha256sum string, size int64, err error) { + dataRoot = filepath.Clean(dataRoot) + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { + return "", 0, err + } + + file, err := os.Create(outputPath) + if err != nil { + return "", 0, err + } + defer file.Close() + + hasher := sha256.New() + writer := io.MultiWriter(file, hasher) + gzipWriter := gzip.NewWriter(writer) + tarWriter := tar.NewWriter(gzipWriter) + + for _, rel := range relativePaths { + abs := filepath.Join(dataRoot, filepath.FromSlash(rel)) + abs = filepath.Clean(abs) + if !strings.HasPrefix(abs, dataRoot+string(os.PathSeparator)) && abs != dataRoot { + return "", 0, fmt.Errorf("path escapes data root: %s", rel) + } + + info, err := os.Stat(abs) + if err != nil { + return "", 0, err + } + + baseName := filepath.Base(abs) + if info.IsDir() { + err = filepath.Walk(abs, func(path string, entry os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + innerRel, err := filepath.Rel(abs, path) + if err != nil { + return err + } + if innerRel == "." { + return nil + } + headerName := filepath.ToSlash(filepath.Join(baseName, innerRel)) + return writeTarEntry(tarWriter, path, entry, headerName) + }) + if err != nil { + return "", 0, err + } + continue + } + + if err := writeTarEntry(tarWriter, abs, info, baseName); err != nil { + return "", 0, err + } + } + + if err := tarWriter.Close(); err != nil { + return "", 0, err + } + if err := gzipWriter.Close(); err != nil { + return "", 0, err + } + + stat, err := file.Stat() + if err != nil { + return "", 0, err + } + + return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil +} + +func writeTarEntry(tarWriter *tar.Writer, path string, entry os.FileInfo, headerName string) error { + header, err := tar.FileInfoHeader(entry, "") + if err != nil { + return err + } + header.Name = headerName + if err := tarWriter.WriteHeader(header); err != nil { + return err + } + if entry.IsDir() { + return nil + } + sourceFile, err := os.Open(path) + if err != nil { + return err + } + defer sourceFile.Close() + _, err = io.Copy(tarWriter, sourceFile) + return err +} + // ExtractTarGz extracts archivePath into destDir. func ExtractTarGz(archivePath, destDir string) error { destDir = filepath.Clean(destDir) diff --git a/apps/node-agent/internal/docker/client.go b/apps/node-agent/internal/docker/client.go index f77518e..7dd06f5 100644 --- a/apps/node-agent/internal/docker/client.go +++ b/apps/node-agent/internal/docker/client.go @@ -113,9 +113,19 @@ func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServer Target: "/data", }}, Resources: container.Resources{ - Memory: int64(spec.RamMb) * 1024 * 1024, + Memory: int64(spec.RamMb) * 1024 * 1024, + PidsLimit: ptrInt64(256), + }, + Privileged: false, + CapDrop: []string{"ALL"}, + SecurityOpt: []string{"no-new-privileges:true"}, + LogConfig: container.LogConfig{ + Type: "json-file", + Config: map[string]string{ + "max-size": "10m", + "max-file": "3", + }, }, - Privileged: false, }, nil, nil, @@ -198,6 +208,10 @@ func mapSoftwareFamily(family string) string { } } +func ptrInt64(value int64) *int64 { + return &value +} + // StreamLogs streams container logs and invokes callback for each line. func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error { opts := container.LogsOptions{ diff --git a/apps/node-agent/internal/files/safe.go b/apps/node-agent/internal/files/safe.go index 8b86184..1cbc62c 100644 --- a/apps/node-agent/internal/files/safe.go +++ b/apps/node-agent/internal/files/safe.go @@ -229,6 +229,31 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error { return nil } +// RemovePath deletes a file or directory relative to dataRoot. +func RemovePath(dataRoot, relativePath string) error { + target, err := ResolvePath(dataRoot, relativePath) + if err != nil { + return err + } + if err := rejectSymlinks(dataRoot, target); err != nil { + return err + } + + info, err := os.Lstat(target) + if err != nil { + if os.IsNotExist(err) { + return ErrNotFound + } + return err + } + + if info.IsDir() { + return os.RemoveAll(target) + } + + return os.Remove(target) +} + // DeleteFile removes a file relative to dataRoot. func DeleteFile(dataRoot, relativePath string) error { filePath, err := ResolvePath(dataRoot, relativePath) diff --git a/apps/node-agent/internal/protocol/messages.go b/apps/node-agent/internal/protocol/messages.go index a4e46e2..2ca273c 100644 --- a/apps/node-agent/internal/protocol/messages.go +++ b/apps/node-agent/internal/protocol/messages.go @@ -38,6 +38,9 @@ const ( TypeServerFilesList MessageType = "server.files.list" TypeServerFilesRead MessageType = "server.files.read" TypeServerFilesWrite MessageType = "server.files.write" + TypeServerFilesDelete MessageType = "server.files.delete" + TypeServerFilesArchive MessageType = "server.files.archive" + TypeServerFilesUnarchive MessageType = "server.files.unarchive" TypeServerFilesUploadDone MessageType = "server.files.upload.complete" TypeServerWorldValidate MessageType = "server.world.validate" TypeServerWorldArchive MessageType = "server.world.archive" @@ -76,6 +79,9 @@ var controlToAgentTypes = map[MessageType]struct{}{ TypeServerFilesList: {}, TypeServerFilesRead: {}, TypeServerFilesWrite: {}, + TypeServerFilesDelete: {}, + TypeServerFilesArchive: {}, + TypeServerFilesUnarchive: {}, TypeServerFilesUploadDone: {}, TypeServerWorldValidate: {}, TypeServerWorldArchive: {}, @@ -185,9 +191,30 @@ type ServerFilesReadPayload struct { // ServerFilesWritePayload writes content to a file in the server data directory. type ServerFilesWritePayload struct { OperationMeta - Path string `json:"path"` - Content string `json:"content"` - Create bool `json:"create"` + Path string `json:"path"` + Content string `json:"content"` + Create bool `json:"create"` + Encoding string `json:"encoding,omitempty"` +} + +// ServerFilesDeletePayload deletes a file or directory relative to data root. +type ServerFilesDeletePayload struct { + OperationMeta + Path string `json:"path"` +} + +// ServerFilesArchivePayload creates a tar.gz archive from paths. +type ServerFilesArchivePayload struct { + OperationMeta + Paths []string `json:"paths"` + ArchiveName string `json:"archiveName,omitempty"` +} + +// ServerFilesUnarchivePayload extracts a tar.gz archive. +type ServerFilesUnarchivePayload struct { + OperationMeta + ArchivePath string `json:"archivePath"` + DestinationPath string `json:"destinationPath"` } // ServerWorldValidatePayload validates a Minecraft world directory. diff --git a/apps/node-agent/internal/runtime/manager.go b/apps/node-agent/internal/runtime/manager.go index cb96ce0..87327bc 100644 --- a/apps/node-agent/internal/runtime/manager.go +++ b/apps/node-agent/internal/runtime/manager.go @@ -4,10 +4,13 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" "os" "path/filepath" + "strings" "sync" + "time" "github.com/hexahost/gamecloud/node-agent/internal/archive" "github.com/hexahost/gamecloud/node-agent/internal/docker" @@ -357,6 +360,88 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa }) } +// HandleFilesDelete removes a file or directory from the server data directory. +func (m *Manager) HandleFilesDelete(ctx context.Context, correlationID string, payload protocol.ServerFilesDeletePayload) { + meta := payload.OperationMeta + rec, ok := m.getServer(meta.ServerID) + if !ok { + m.failOperation(ctx, correlationID, meta, "files.delete", "SERVER_NOT_FOUND", "server is not provisioned on this node") + return + } + + if err := files.RemovePath(rec.DataPath, payload.Path); err != nil { + m.failOperation(ctx, correlationID, meta, "files.delete", filesErrorCode(err), err.Error()) + return + } + + m.completeOperation(ctx, correlationID, meta, "files.delete", map[string]string{ + "path": payload.Path, + }) +} + +// HandleFilesArchive creates a tar.gz archive from selected paths. +func (m *Manager) HandleFilesArchive(ctx context.Context, correlationID string, payload protocol.ServerFilesArchivePayload) { + meta := payload.OperationMeta + rec, ok := m.getServer(meta.ServerID) + if !ok { + m.failOperation(ctx, correlationID, meta, "files.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node") + return + } + + archiveName := payload.ArchiveName + if archiveName == "" { + archiveName = fmt.Sprintf("archive-%d.tar.gz", time.Now().Unix()) + } + if !strings.HasSuffix(archiveName, ".tar.gz") { + archiveName += ".tar.gz" + } + + outputPath := filepath.Join(rec.DataPath, ".hexahost-archives", archiveName) + checksum, size, err := archive.CreateTarGzFromPaths(rec.DataPath, payload.Paths, outputPath) + if err != nil { + m.failOperation(ctx, correlationID, meta, "files.archive", "ARCHIVE_FAILED", err.Error()) + return + } + + rel, _ := filepath.Rel(rec.DataPath, outputPath) + m.completeOperation(ctx, correlationID, meta, "files.archive", map[string]any{ + "archivePath": filepath.ToSlash(rel), + "sha256": checksum, + "size": size, + }) +} + +// HandleFilesUnarchive extracts a tar.gz archive into a destination path. +func (m *Manager) HandleFilesUnarchive(ctx context.Context, correlationID string, payload protocol.ServerFilesUnarchivePayload) { + meta := payload.OperationMeta + rec, ok := m.getServer(meta.ServerID) + if !ok { + m.failOperation(ctx, correlationID, meta, "files.unarchive", "SERVER_NOT_FOUND", "server is not provisioned on this node") + return + } + + archiveAbs, err := files.ResolvePath(rec.DataPath, payload.ArchivePath) + if err != nil { + m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error()) + return + } + + destAbs, err := files.ResolvePath(rec.DataPath, payload.DestinationPath) + if err != nil { + m.failOperation(ctx, correlationID, meta, "files.unarchive", filesErrorCode(err), err.Error()) + return + } + + if err := archive.ExtractTarGz(archiveAbs, destAbs); err != nil { + m.failOperation(ctx, correlationID, meta, "files.unarchive", "UNARCHIVE_FAILED", err.Error()) + return + } + + m.completeOperation(ctx, correlationID, meta, "files.unarchive", map[string]string{ + "destinationPath": payload.DestinationPath, + }) +} + // HandleBackupPrepare runs save-off and save-all flush via RCON. func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) { rec, ok := m.getServer(meta.ServerID) diff --git a/apps/node-agent/internal/ws/client.go b/apps/node-agent/internal/ws/client.go index de2de65..6de1233 100644 --- a/apps/node-agent/internal/ws/client.go +++ b/apps/node-agent/internal/ws/client.go @@ -314,6 +314,30 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) { } c.runtime.HandleFilesWrite(ctx, env.MessageID, payload) + case protocol.TypeServerFilesDelete: + var payload protocol.ServerFilesDeletePayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + c.log.Warn("decode server.files.delete", "error", err) + return + } + c.runtime.HandleFilesDelete(ctx, env.MessageID, payload) + + case protocol.TypeServerFilesArchive: + var payload protocol.ServerFilesArchivePayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + c.log.Warn("decode server.files.archive", "error", err) + return + } + c.runtime.HandleFilesArchive(ctx, env.MessageID, payload) + + case protocol.TypeServerFilesUnarchive: + var payload protocol.ServerFilesUnarchivePayload + if err := json.Unmarshal(env.Payload, &payload); err != nil { + c.log.Warn("decode server.files.unarchive", "error", err) + return + } + c.runtime.HandleFilesUnarchive(ctx, env.MessageID, payload) + case protocol.TypeServerBackupPrepare: var meta protocol.OperationMeta if err := json.Unmarshal(env.Payload, &meta); err != nil { diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 97baa26..71ad2b6 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -15,10 +15,19 @@ "cancel": "Abbrechen", "back": "Zurück", "footer": "Selbstgehostete Minecraft-Server-Plattform.", - "servers": "Server" + "servers": "Server", + "features": "Funktionen", + "pricing": "Preise", + "docs": "Dokumentation", + "status": "Status", + "impressum": "Impressum", + "privacy": "Datenschutz", + "terms": "AGB", + "admin": "Admin", + "phaseBadge": "MVP · Phasen 0–9" }, "landing": { - "badge": "Phase 0 · Control Plane", + "badge": "MVP · Phase 9 abgeschlossen", "title": "Minecraft-Server on demand", "subtitle": "Erstellen, konfigurieren und betreiben Sie Minecraft-Server über ein zentrales Webpanel — mandantenfähig, skalierbar und produktionsbereit.", "ctaPrimary": "Jetzt starten", @@ -205,7 +214,36 @@ "players": "Spieler", "worlds": "Welten", "backups": "Backups", - "addons": "Add-ons" + "addons": "Add-ons", + "software": "Software", + "schedules": "Zeitpläne", + "access": "Zugriffe" + }, + "wizard": { + "stepsLabel": "Erstellungsschritte", + "steps": { + "basics": "Grundlagen", + "software": "Software", + "review": "Prüfen" + }, + "next": "Weiter", + "back": "Zurück" + }, + "software": { + "title": "Software", + "description": "Installierte Server-Software und Version.", + "loading": "Wird geladen…", + "loadError": "Software-Informationen konnten nicht geladen werden.", + "family": "Software-Familie", + "version": "Minecraft-Version", + "edition": "Edition", + "changeHint": "Software-Wechsel wird in einer zukünftigen Version unterstützt.", + "reinstall": "Software neu installieren", + "reinstalling": "Wird neu installiert…", + "reinstallConfirm": "Server-Software wirklich neu installieren? Zuerst wird ein Backup erstellt.", + "reinstallStarted": "Neuinstallation gestartet. Der Server ist während der Installation nicht verfügbar.", + "reinstallError": "Neuinstallation konnte nicht gestartet werden.", + "reinstallStoppedHint": "Stoppen Sie den Server, bevor Sie die Software neu installieren." }, "console": { "loading": "Wird geladen…", @@ -233,10 +271,27 @@ "save": "Speichern", "saving": "Wird gespeichert…", "saved": "Datei gespeichert.", + "upload": "Hochladen", + "uploading": "Wird hochgeladen…", + "uploaded": "Datei hochgeladen.", + "delete": "Löschen", + "deleted": "Gelöscht.", + "deleteConfirm": "{name} wirklich löschen?", + "archive": "Auswahl archivieren", + "archiving": "Wird archiviert…", + "archived": "Archiv erstellt.", + "unarchive": "Archiv entpacken", + "unarchiving": "Wird entpackt…", + "unarchived": "Archiv-Entpackung gestartet.", + "unarchiveConfirm": "{name} ins aktuelle Verzeichnis entpacken?", "errors": { "loadFailed": "Verzeichnis konnte nicht geladen werden.", "readFailed": "Datei konnte nicht gelesen werden.", - "saveFailed": "Datei konnte nicht gespeichert werden." + "saveFailed": "Datei konnte nicht gespeichert werden.", + "deleteFailed": "Datei konnte nicht gelöscht werden.", + "uploadFailed": "Datei konnte nicht hochgeladen werden.", + "archiveFailed": "Archiv konnte nicht erstellt werden.", + "unarchiveFailed": "Archiv konnte nicht entpackt werden." } }, "properties": { @@ -280,14 +335,32 @@ "loading": "Wird geladen…", "onlineCount": "Spieler online", "whitelistCount": "Whitelist-Einträge", + "operatorsCount": "Operatoren", + "onlineTitle": "Spieler online", + "whitelistTitle": "Whitelist", + "operatorsTitle": "Operatoren", + "bansTitle": "Gebannte Spieler", + "bansEmpty": "Keine gebannten Spieler.", "empty": "Keine Spieler online.", "retry": "Erneut versuchen", + "kick": "Kicken", + "addWhitelist": "Zur Whitelist hinzufügen", + "addOperator": "Operator hinzufügen", + "addBan": "Spieler bannen", + "remove": "Entfernen", + "fields": { + "name": "Spielername", + "uuid": "UUID", + "reason": "Grund" + }, "columns": { "name": "Spieler", - "ping": "Ping" + "ping": "Ping", + "actions": "Aktionen" }, "errors": { - "loadFailed": "Spielerliste konnte nicht geladen werden." + "loadFailed": "Spielerliste konnte nicht geladen werden.", + "actionFailed": "Aktion fehlgeschlagen." } }, "worlds": { @@ -347,6 +420,473 @@ "remove": "Entfernen", "installed": "Installiert", "empty": "Noch keine Add-ons installiert." + }, + "schedules": { + "title": "Zeitpläne", + "description": "Geplante Start-, Stopp- und Wartungsaktionen.", + "loading": "Wird geladen…", + "empty": "Noch keine Zeitpläne vorhanden.", + "create": "Zeitplan erstellen", + "edit": "Bearbeiten", + "save": "Änderungen speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "enable": "Aktivieren", + "disable": "Deaktivieren", + "saving": "Wird gespeichert…", + "deleteConfirm": "Zeitplan \"{name}\" wirklich löschen?", + "fields": { + "name": "Name", + "cron": "Cron-Ausdruck", + "timezone": "Zeitzone", + "taskType": "Aufgabe", + "enabled": "Aktiv", + "command": "Konsolenbefehl", + "commandPlaceholder": "say Server startet neu" + }, + "columns": { + "name": "Name", + "cron": "Cron", + "task": "Aufgabe", + "status": "Status", + "actions": "Aktionen" + }, + "status": { + "enabled": "Aktiv", + "disabled": "Inaktiv" + }, + "taskTypes": { + "START": "Server starten", + "STOP": "Server stoppen", + "RESTART": "Server neu starten", + "BACKUP": "Backup erstellen", + "CONSOLE_COMMAND": "Befehl ausführen", + "UPDATE_CHECK": "Updates prüfen", + "MAINTENANCE": "Wartung" + }, + "errors": { + "loadFailed": "Zeitpläne konnten nicht geladen werden.", + "saveFailed": "Zeitplan konnte nicht gespeichert werden.", + "deleteFailed": "Zeitplan konnte nicht gelöscht werden." + } + }, + "access": { + "title": "Zugriffe", + "description": "Freigaben und Berechtigungen für diesen Server.", + "loading": "Wird geladen…", + "empty": "Noch keine Mitglieder. Laden Sie jemanden ein, um diesen Server zu teilen.", + "invite": "Mitglied einladen", + "sendInvite": "Einladung senden", + "inviting": "Wird gesendet…", + "cancel": "Abbrechen", + "remove": "Entfernen", + "inviteSuccess": "Mitglied erfolgreich eingeladen.", + "invitePending": "Einladung vermerkt. Der Benutzer muss sich registrieren, bevor er auf diesen Server zugreifen kann.", + "removeConfirm": "{name} von diesem Server entfernen?", + "fields": { + "email": "E-Mail-Adresse", + "emailPlaceholder": "benutzer@beispiel.de", + "role": "Rolle" + }, + "columns": { + "member": "Mitglied", + "email": "E-Mail", + "role": "Rolle", + "status": "Status", + "actions": "Aktionen" + }, + "roles": { + "OWNER": "Inhaber", + "ADMIN": "Administrator", + "OPERATOR": "Operator", + "DEVELOPER": "Entwickler", + "VIEWER": "Betrachter" + }, + "status": { + "active": "Aktiv", + "pending": "Ausstehend" + }, + "errors": { + "loadFailed": "Mitglieder konnten nicht geladen werden.", + "inviteFailed": "Einladung konnte nicht gesendet werden.", + "removeFailed": "Mitglied konnte nicht entfernt werden." + } + } + }, + "account": { + "nav": { + "label": "Kontonavigation", + "dashboard": "Dashboard", + "servers": "Server", + "billing": "Abrechnung", + "profile": "Profil", + "notifications": "Benachrichtigungen", + "security": "Sicherheit" + }, + "billing": { + "title": "Abrechnung", + "subtitle": "Credits und Transaktionsverlauf.", + "loading": "Wird geladen…", + "loadError": "Abrechnungsdaten konnten nicht geladen werden.", + "balanceTitle": "Aktuelles Guthaben", + "periodStart": "Abrechnungszeitraum ab {date}", + "creditsLabel": "Verbleibende Credits", + "transactionsTitle": "Transaktionen", + "transactionsEmpty": "Noch keine Transaktionen in diesem Zeitraum.", + "columns": { + "date": "Datum", + "type": "Typ", + "amount": "Betrag", + "reference": "Referenz" + } + }, + "profile": { + "title": "Profil", + "subtitle": "Ihre Kontoinformationen.", + "loading": "Wird geladen…", + "notSignedIn": "Nicht angemeldet.", + "accountInfo": "Kontodaten", + "accountInfoDescription": "Profilinformationen aus Ihrem Konto.", + "fields": { + "email": "E-Mail", + "username": "Benutzername", + "displayName": "Anzeigename", + "emailVerified": "E-Mail verifiziert", + "twoFactor": "Zwei-Faktor-Auth" + }, + "yes": "Ja", + "no": "Nein", + "enabled": "Aktiv", + "disabled": "Inaktiv", + "securityHint": "Passwort, 2FA und Sitzungen verwalten unter", + "securityLink": "Sicherheit" + }, + "notifications": { + "title": "Benachrichtigungen", + "subtitle": "E-Mail- und In-App-Benachrichtigungen.", + "loading": "Wird geladen…", + "listTitle": "Neueste Benachrichtigungen", + "unreadCount": "{count, plural, one {# ungelesene Benachrichtigung} other {# ungelesene Benachrichtigungen}}", + "allRead": "Alles gelesen.", + "markRead": "Als gelesen markieren", + "markAllRead": "Alle als gelesen markieren", + "emptyTitle": "Keine Benachrichtigungen", + "emptyDescription": "Sie haben noch keine Benachrichtigungen erhalten.", + "preferencesTitle": "Einstellungen", + "preferencesDescription": "Wählen Sie, welche Benachrichtigungen Sie pro Kanal erhalten.", + "enabled": "Aktiv", + "disabled": "Inaktiv", + "channels": { + "IN_APP": "In-App", + "EMAIL": "E-Mail", + "WEBHOOK": "Webhook", + "DISCORD": "Discord" + }, + "types": { + "SERVER_READY": "Server bereit", + "SERVER_START_FAILED": "Serverstart fehlgeschlagen", + "SERVER_STOPPED": "Server gestoppt", + "IDLE_STOP_WARNING": "Leerlauf-Stopp-Warnung", + "BACKUP_SUCCESS": "Backup erfolgreich", + "BACKUP_FAILED": "Backup fehlgeschlagen", + "RESTORE_SUCCESS": "Wiederherstellung erfolgreich", + "RESTORE_FAILED": "Wiederherstellung fehlgeschlagen", + "CREDITS_LOW": "Wenig Credits", + "BILLING_ISSUE": "Abrechnungsproblem", + "INVITE": "Einladung", + "NODE_INCIDENT": "Node-Vorfall", + "MAINTENANCE": "Wartung", + "SECURITY": "Sicherheit", + "NEW_LOGIN": "Neue Anmeldung", + "TWO_FA_CHANGED": "Zwei-Faktor geändert" + }, + "errors": { + "loadFailed": "Benachrichtigungen konnten nicht geladen werden.", + "markReadFailed": "Benachrichtigung konnte nicht aktualisiert werden.", + "preferencesFailed": "Einstellungen konnten nicht gespeichert werden." + } + } + }, + "admin": { + "backToPanel": "Zurück zum Panel", + "nav": { + "label": "Admin-Navigation", + "dashboard": "Dashboard", + "nodes": "Nodes", + "users": "Benutzer", + "jobs": "Jobs", + "audit": "Audit" + }, + "dashboard": { + "title": "Admin-Dashboard", + "subtitle": "Plattformweite Kennzahlen und Systemstatus.", + "loading": "Wird geladen…", + "loadError": "Dashboard-Daten konnten nicht geladen werden.", + "generatedAt": "Stand: {time}", + "stats": { + "activeUsers": "Aktive Benutzer", + "totalServers": "Server gesamt", + "runningServers": "Laufende Server", + "queueLength": "Warteschlange", + "onlineNodes": "Online-Nodes", + "failedJobs24h": "Fehlgeschlagene Jobs (24h)", + "openAbuseReports": "Offene Abuse-Reports", + "openTickets": "Offene Tickets" + } + }, + "nodes": { + "title": "Game-Nodes", + "subtitle": "Node-Status und Wartungsaktionen.", + "loading": "Wird geladen…", + "loadError": "Nodes konnten nicht geladen werden.", + "empty": "Keine Nodes registriert.", + "drain": "Drain", + "maintenance": "Wartung", + "exitMaintenance": "Wartung beenden", + "actionError": "Aktion fehlgeschlagen.", + "columns": { + "name": "Node", + "status": "Status", + "servers": "Server", + "ram": "RAM", + "actions": "Aktionen" + } + }, + "users": { + "title": "Benutzer", + "subtitle": "Benutzerkonten suchen und verwalten.", + "loading": "Wird geladen…", + "loadError": "Benutzer konnten nicht geladen werden.", + "empty": "Keine Benutzer gefunden.", + "searchPlaceholder": "E-Mail oder Benutzername…", + "search": "Suchen", + "suspend": "Sperren", + "unsuspend": "Entsperren", + "actionError": "Aktion fehlgeschlagen.", + "columns": { + "user": "Benutzer", + "status": "Status", + "roles": "Rollen", + "servers": "Server", + "actions": "Aktionen" + } + }, + "jobs": { + "title": "Hintergrund-Jobs", + "subtitle": "Aktuelle und kürzlich abgeschlossene Queue-Jobs.", + "loading": "Wird geladen…", + "loadError": "Jobs konnten nicht geladen werden.", + "empty": "Keine Jobs gefunden.", + "columns": { + "queue": "Queue", + "job": "Job", + "status": "Status", + "attempts": "Versuche", + "created": "Erstellt" + } + }, + "audit": { + "title": "Audit-Log", + "subtitle": "Plattformweite Sicherheits- und Lifecycle-Ereignisse.", + "loading": "Wird geladen…", + "loadError": "Audit-Ereignisse konnten nicht geladen werden.", + "empty": "Keine Ereignisse gefunden.", + "loadMore": "Mehr laden", + "columns": { + "time": "Zeit", + "action": "Aktion", + "user": "Benutzer", + "entity": "Entität" + } + } + }, + "public": { + "pricing": { + "title": "Preise", + "description": "Transparente Tarife für Minecraft-Server-Hosting.", + "cta": "Jetzt registrieren →", + "tiers": { + "starter": { + "name": "Starter", + "description": "Ideal zum Ausprobieren.", + "price": "Kostenlos", + "features": ["2 GB RAM", "1 Server", "Idle-Shutdown", "Community-Support"] + }, + "standard": { + "name": "Standard", + "description": "Für kleine Communities.", + "price": "Credits", + "features": ["4 GB RAM", "3 Server", "Backups", "Modrinth Add-ons"] + }, + "pro": { + "name": "Pro", + "description": "Für anspruchsvolle Welten.", + "price": "Credits", + "features": ["8+ GB RAM", "Unbegrenzte Server*", "Prioritäts-Queue", "WHMCS-Integration"] + } + } + }, + "features": { + "title": "Funktionen", + "description": "Alles für professionelles Minecraft-Server-Hosting.", + "items": { + "control": { + "title": "Zentrale Steuerung", + "description": "Lifecycle, Konsole, Dateien und Properties über ein einheitliches Panel." + }, + "nodes": { + "title": "Multi-Node", + "description": "Horizontale Skalierung mit Agent-basierter Orchestrierung." + }, + "security": { + "title": "Sicherheit", + "description": "Sessions, 2FA, CSRF-Schutz und auditierbare Aktionen." + }, + "billing": { + "title": "Credit-Abrechnung", + "description": "Laufzeitbasierte Abrechnung mit transparentem Guthaben." + }, + "backups": { + "title": "Backups & Welten", + "description": "Manuelle und geplante Backups, Welt-Upload und -Download." + }, + "whmcs": { + "title": "WHMCS-Integration", + "description": "Provisioning, SSO und Abrechnung über WHMCS." + } + } + }, + "status": { + "title": "Systemstatus", + "description": "Aktueller Betriebsstatus der Plattformkomponenten.", + "operational": "Betriebsbereit", + "selfHostedNote": "Status basiert auf Self-Hosted-Deployment. Konfigurieren Sie Health-Checks für Live-Monitoring.", + "components": { + "api": { + "name": "API", + "description": "REST Control Plane unter /api/v1" + }, + "worker": { + "name": "Worker", + "description": "BullMQ Hintergrundverarbeitung" + }, + "panel": { + "name": "Web-Panel", + "description": "Next.js Benutzeroberfläche" + }, + "dns": { + "name": "DNS / Edge", + "description": "Join-to-Start und Edge-Gateway" + } + } + }, + "docs": { + "title": "Dokumentation", + "description": "Einstieg, API-Referenz und Betriebshinweise.", + "openapi": "Interaktive API-Dokumentation:", + "openapiLink": "OpenAPI / Swagger", + "cta": "Konto erstellen →", + "sections": { + "gettingStarted": { + "title": "Erste Schritte", + "body": "Registrieren Sie sich, verifizieren Sie Ihre E-Mail, erstellen Sie einen Server über den mehrstufigen Assistenten und starten Sie ihn über das Dashboard." + }, + "api": { + "title": "API", + "body": "Die REST-API unter /api/v1 bietet Endpunkte für Auth, Server-Lifecycle, Dateien, Backups und Admin-Funktionen. Authentifizierung erfolgt über Session-Cookies." + }, + "operations": { + "title": "Betrieb", + "body": "Deployen Sie Control Plane, Worker und Node-Agent über Docker Compose oder Ansible. Siehe docs/operations im Repository." + }, + "whmcs": { + "title": "WHMCS", + "body": "Das Server-Modul provisioniert Dienste automatisch. SSO ermöglicht nahtlosen Zugang aus dem WHMCS Client Area." + } + } + }, + "impressum": { + "title": "Impressum", + "description": "Angaben gemäß § 5 TMG.", + "notice": "Dies ist ein Impressums-Entwurf. Ersetzen Sie die Platzhalterangaben vor dem Produktivbetrieb.", + "sections": { + "operator": { + "title": "Anbieter", + "body": "HexaHost\nPlattform für Game-Server-Hosting" + }, + "address": { + "title": "Anschrift", + "body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nDeutschland" + }, + "contact": { + "title": "Kontakt", + "body": "E-Mail: kontakt@hexahost.example\nTelefon: +49 (0) 123 456789" + }, + "responsible": { + "title": "Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV", + "body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt" + }, + "register": { + "title": "Registereintrag", + "body": "Registergericht: Amtsgericht Musterstadt\nRegisternummer: HRB 12345\nUSt-IdNr.: DE123456789" + }, + "dispute": { + "title": "Streitbeilegung", + "body": "Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen." + } + } + }, + "privacy": { + "title": "Datenschutzerklärung", + "description": "Informationen zur Verarbeitung personenbezogener Daten.", + "sections": { + "controller": { + "title": "Verantwortlicher", + "body": "Der Betreiber dieser selbstgehosteten Instanz ist für die Datenverarbeitung verantwortlich. Passen Sie diese Erklärung an Ihre Organisation an." + }, + "data": { + "title": "Erhobene Daten", + "body": "Wir verarbeiten Kontodaten (E-Mail, Benutzername), Sitzungsinformationen, Server-Metadaten und Nutzungsdaten zur Abrechnung." + }, + "cookies": { + "title": "Cookies", + "body": "Session-Cookies (hgc_session) und CSRF-Cookies (hgc_csrf) sind für die Authentifizierung erforderlich." + }, + "rights": { + "title": "Ihre Rechte", + "body": "Sie haben das Recht auf Auskunft, Berichtigung, Löschung und Datenübertragbarkeit gemäß DSGVO." + }, + "contact": { + "title": "Kontakt", + "body": "Wenden Sie sich für Datenschutzanfragen an den in Ihrem Impressum genannten Betreiber." + } + } + }, + "terms": { + "title": "Allgemeine Geschäftsbedingungen", + "description": "Nutzungsbedingungen der Plattform.", + "sections": { + "scope": { + "title": "Geltungsbereich", + "body": "Diese AGB gelten für die Nutzung der Minecraft-Server-Hosting-Plattform durch registrierte Benutzer." + }, + "usage": { + "title": "Nutzung", + "body": "Server dürfen nur für legale Zwecke betrieben werden. Missbrauch kann zur Sperrung führen." + }, + "billing": { + "title": "Abrechnung", + "body": "Credits werden nach Laufzeit verbraucht. Guthaben ist nicht übertragbar, sofern nicht anders vereinbart." + }, + "liability": { + "title": "Haftung", + "body": "Der Betreiber haftet nur bei Vorsatz und grober Fahrlässigkeit. Datenverluste sind durch eigene Backups abzusichern." + }, + "changes": { + "title": "Änderungen", + "body": "Der Betreiber kann diese Bedingungen mit angemessener Frist ändern. Fortgesetzte Nutzung gilt als Zustimmung." + } + } } } } diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 8635d08..4c5ab38 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -15,10 +15,19 @@ "cancel": "Cancel", "back": "Back", "footer": "Self-hosted Minecraft server platform.", - "servers": "Servers" + "servers": "Servers", + "features": "Features", + "pricing": "Pricing", + "docs": "Documentation", + "status": "Status", + "impressum": "Legal notice", + "privacy": "Privacy", + "terms": "Terms", + "admin": "Admin", + "phaseBadge": "MVP · Phases 0–9" }, "landing": { - "badge": "Phase 0 · Control Plane", + "badge": "MVP · Phase 9 complete", "title": "Minecraft servers on demand", "subtitle": "Create, configure, and operate Minecraft servers through a central web panel — multi-tenant, scalable, and production-ready.", "ctaPrimary": "Get started", @@ -205,7 +214,36 @@ "players": "Players", "worlds": "Worlds", "backups": "Backups", - "addons": "Add-ons" + "addons": "Add-ons", + "software": "Software", + "schedules": "Schedules", + "access": "Access" + }, + "wizard": { + "stepsLabel": "Creation steps", + "steps": { + "basics": "Basics", + "software": "Software", + "review": "Review" + }, + "next": "Next", + "back": "Back" + }, + "software": { + "title": "Software", + "description": "Installed server software and version.", + "loading": "Loading…", + "loadError": "Could not load software information.", + "family": "Software family", + "version": "Minecraft version", + "edition": "Edition", + "changeHint": "Software switching will be supported in a future release.", + "reinstall": "Reinstall software", + "reinstalling": "Reinstalling…", + "reinstallConfirm": "Reinstall server software? A backup will be created first.", + "reinstallStarted": "Reinstall started. The server will be unavailable during installation.", + "reinstallError": "Could not start reinstall.", + "reinstallStoppedHint": "Stop the server before reinstalling software." }, "console": { "loading": "Loading…", @@ -233,10 +271,27 @@ "save": "Save", "saving": "Saving…", "saved": "File saved.", + "upload": "Upload", + "uploading": "Uploading…", + "uploaded": "File uploaded.", + "delete": "Delete", + "deleted": "Deleted.", + "deleteConfirm": "Delete {name}?", + "archive": "Archive selected", + "archiving": "Archiving…", + "archived": "Archive created.", + "unarchive": "Extract archive", + "unarchiving": "Extracting…", + "unarchived": "Archive extraction started.", + "unarchiveConfirm": "Extract {name} into the current directory?", "errors": { "loadFailed": "Could not load directory.", "readFailed": "Could not read file.", - "saveFailed": "Could not save file." + "saveFailed": "Could not save file.", + "deleteFailed": "Could not delete file.", + "uploadFailed": "Could not upload file.", + "archiveFailed": "Could not create archive.", + "unarchiveFailed": "Could not extract archive." } }, "properties": { @@ -280,14 +335,32 @@ "loading": "Loading…", "onlineCount": "Online players", "whitelistCount": "Whitelist entries", + "operatorsCount": "Operators", + "onlineTitle": "Online players", + "whitelistTitle": "Whitelist", + "operatorsTitle": "Operators", + "bansTitle": "Banned players", + "bansEmpty": "No banned players.", "empty": "No players online.", "retry": "Retry", + "kick": "Kick", + "addWhitelist": "Add to whitelist", + "addOperator": "Add operator", + "addBan": "Ban player", + "remove": "Remove", + "fields": { + "name": "Player name", + "uuid": "UUID", + "reason": "Reason" + }, "columns": { "name": "Player", - "ping": "Ping" + "ping": "Ping", + "actions": "Actions" }, "errors": { - "loadFailed": "Could not load player list." + "loadFailed": "Could not load player list.", + "actionFailed": "Action failed." } }, "worlds": { @@ -347,6 +420,473 @@ "remove": "Remove", "installed": "Installed", "empty": "No add-ons installed yet." + }, + "schedules": { + "title": "Schedules", + "description": "Scheduled start, stop, and maintenance actions.", + "loading": "Loading…", + "empty": "No schedules yet.", + "create": "Create schedule", + "edit": "Edit", + "save": "Save changes", + "cancel": "Cancel", + "delete": "Delete", + "enable": "Enable", + "disable": "Disable", + "saving": "Saving…", + "deleteConfirm": "Delete schedule \"{name}\"?", + "fields": { + "name": "Name", + "cron": "Cron expression", + "timezone": "Timezone", + "taskType": "Task", + "enabled": "Enabled", + "command": "Console command", + "commandPlaceholder": "say Server restarting" + }, + "columns": { + "name": "Name", + "cron": "Cron", + "task": "Task", + "status": "Status", + "actions": "Actions" + }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled" + }, + "taskTypes": { + "START": "Start server", + "STOP": "Stop server", + "RESTART": "Restart server", + "BACKUP": "Create backup", + "CONSOLE_COMMAND": "Run command", + "UPDATE_CHECK": "Check for updates", + "MAINTENANCE": "Maintenance" + }, + "errors": { + "loadFailed": "Could not load schedules.", + "saveFailed": "Could not save schedule.", + "deleteFailed": "Could not delete schedule." + } + }, + "access": { + "title": "Access", + "description": "Sharing and permissions for this server.", + "loading": "Loading…", + "empty": "No members yet. Invite someone to share this server.", + "invite": "Invite member", + "sendInvite": "Send invite", + "inviting": "Sending…", + "cancel": "Cancel", + "remove": "Remove", + "inviteSuccess": "Member invited successfully.", + "invitePending": "Invite noted. The user must register before they can access this server.", + "removeConfirm": "Remove {name} from this server?", + "fields": { + "email": "Email address", + "emailPlaceholder": "user@example.com", + "role": "Role" + }, + "columns": { + "member": "Member", + "email": "Email", + "role": "Role", + "status": "Status", + "actions": "Actions" + }, + "roles": { + "OWNER": "Owner", + "ADMIN": "Admin", + "OPERATOR": "Operator", + "DEVELOPER": "Developer", + "VIEWER": "Viewer" + }, + "status": { + "active": "Active", + "pending": "Pending" + }, + "errors": { + "loadFailed": "Could not load members.", + "inviteFailed": "Could not send invite.", + "removeFailed": "Could not remove member." + } + } + }, + "account": { + "nav": { + "label": "Account navigation", + "dashboard": "Dashboard", + "servers": "Servers", + "billing": "Billing", + "profile": "Profile", + "notifications": "Notifications", + "security": "Security" + }, + "billing": { + "title": "Billing", + "subtitle": "Credits and transaction history.", + "loading": "Loading…", + "loadError": "Could not load billing data.", + "balanceTitle": "Current balance", + "periodStart": "Billing period from {date}", + "creditsLabel": "Credits remaining", + "transactionsTitle": "Transactions", + "transactionsEmpty": "No transactions in this period yet.", + "columns": { + "date": "Date", + "type": "Type", + "amount": "Amount", + "reference": "Reference" + } + }, + "profile": { + "title": "Profile", + "subtitle": "Your account information.", + "loading": "Loading…", + "notSignedIn": "Not signed in.", + "accountInfo": "Account details", + "accountInfoDescription": "Profile information from your account.", + "fields": { + "email": "Email", + "username": "Username", + "displayName": "Display name", + "emailVerified": "Email verified", + "twoFactor": "Two-factor auth" + }, + "yes": "Yes", + "no": "No", + "enabled": "Enabled", + "disabled": "Disabled", + "securityHint": "Manage password, 2FA, and sessions at", + "securityLink": "Security" + }, + "notifications": { + "title": "Notifications", + "subtitle": "Email and in-app notifications.", + "loading": "Loading…", + "listTitle": "Recent notifications", + "unreadCount": "{count, plural, one {# unread notification} other {# unread notifications}}", + "allRead": "All caught up.", + "markRead": "Mark read", + "markAllRead": "Mark all read", + "emptyTitle": "No notifications", + "emptyDescription": "You have not received any notifications yet.", + "preferencesTitle": "Preferences", + "preferencesDescription": "Choose which notifications you receive on each channel.", + "enabled": "Enabled", + "disabled": "Disabled", + "channels": { + "IN_APP": "In-app", + "EMAIL": "Email", + "WEBHOOK": "Webhook", + "DISCORD": "Discord" + }, + "types": { + "SERVER_READY": "Server ready", + "SERVER_START_FAILED": "Server start failed", + "SERVER_STOPPED": "Server stopped", + "IDLE_STOP_WARNING": "Idle stop warning", + "BACKUP_SUCCESS": "Backup succeeded", + "BACKUP_FAILED": "Backup failed", + "RESTORE_SUCCESS": "Restore succeeded", + "RESTORE_FAILED": "Restore failed", + "CREDITS_LOW": "Low credits", + "BILLING_ISSUE": "Billing issue", + "INVITE": "Invite", + "NODE_INCIDENT": "Node incident", + "MAINTENANCE": "Maintenance", + "SECURITY": "Security", + "NEW_LOGIN": "New login", + "TWO_FA_CHANGED": "Two-factor changed" + }, + "errors": { + "loadFailed": "Could not load notifications.", + "markReadFailed": "Could not update notification.", + "preferencesFailed": "Could not save preferences." + } + } + }, + "admin": { + "backToPanel": "Back to panel", + "nav": { + "label": "Admin navigation", + "dashboard": "Dashboard", + "nodes": "Nodes", + "users": "Users", + "jobs": "Jobs", + "audit": "Audit" + }, + "dashboard": { + "title": "Admin dashboard", + "subtitle": "Platform-wide metrics and system status.", + "loading": "Loading…", + "loadError": "Could not load dashboard data.", + "generatedAt": "As of {time}", + "stats": { + "activeUsers": "Active users", + "totalServers": "Total servers", + "runningServers": "Running servers", + "queueLength": "Queue length", + "onlineNodes": "Online nodes", + "failedJobs24h": "Failed jobs (24h)", + "openAbuseReports": "Open abuse reports", + "openTickets": "Open tickets" + } + }, + "nodes": { + "title": "Game nodes", + "subtitle": "Node status and maintenance actions.", + "loading": "Loading…", + "loadError": "Could not load nodes.", + "empty": "No nodes registered.", + "drain": "Drain", + "maintenance": "Maintenance", + "exitMaintenance": "Exit maintenance", + "actionError": "Action failed.", + "columns": { + "name": "Node", + "status": "Status", + "servers": "Servers", + "ram": "RAM", + "actions": "Actions" + } + }, + "users": { + "title": "Users", + "subtitle": "Search and manage user accounts.", + "loading": "Loading…", + "loadError": "Could not load users.", + "empty": "No users found.", + "searchPlaceholder": "Email or username…", + "search": "Search", + "suspend": "Suspend", + "unsuspend": "Unsuspend", + "actionError": "Action failed.", + "columns": { + "user": "User", + "status": "Status", + "roles": "Roles", + "servers": "Servers", + "actions": "Actions" + } + }, + "jobs": { + "title": "Background jobs", + "subtitle": "Current and recently completed queue jobs.", + "loading": "Loading…", + "loadError": "Could not load jobs.", + "empty": "No jobs found.", + "columns": { + "queue": "Queue", + "job": "Job", + "status": "Status", + "attempts": "Attempts", + "created": "Created" + } + }, + "audit": { + "title": "Audit log", + "subtitle": "Platform-wide security and lifecycle events.", + "loading": "Loading…", + "loadError": "Could not load audit events.", + "empty": "No events found.", + "loadMore": "Load more", + "columns": { + "time": "Time", + "action": "Action", + "user": "User", + "entity": "Entity" + } + } + }, + "public": { + "pricing": { + "title": "Pricing", + "description": "Transparent plans for Minecraft server hosting.", + "cta": "Register now →", + "tiers": { + "starter": { + "name": "Starter", + "description": "Great for trying things out.", + "price": "Free", + "features": ["2 GB RAM", "1 server", "Idle shutdown", "Community support"] + }, + "standard": { + "name": "Standard", + "description": "For small communities.", + "price": "Credits", + "features": ["4 GB RAM", "3 servers", "Backups", "Modrinth add-ons"] + }, + "pro": { + "name": "Pro", + "description": "For demanding worlds.", + "price": "Credits", + "features": ["8+ GB RAM", "Unlimited servers*", "Priority queue", "WHMCS integration"] + } + } + }, + "features": { + "title": "Features", + "description": "Everything for professional Minecraft server hosting.", + "items": { + "control": { + "title": "Central control", + "description": "Lifecycle, console, files, and properties through a unified panel." + }, + "nodes": { + "title": "Multi-node", + "description": "Horizontal scaling with agent-based orchestration." + }, + "security": { + "title": "Security", + "description": "Sessions, 2FA, CSRF protection, and auditable actions." + }, + "billing": { + "title": "Credit billing", + "description": "Runtime-based billing with transparent balance." + }, + "backups": { + "title": "Backups & worlds", + "description": "Manual and scheduled backups, world upload and download." + }, + "whmcs": { + "title": "WHMCS integration", + "description": "Provisioning, SSO, and billing through WHMCS." + } + } + }, + "status": { + "title": "System status", + "description": "Current operational status of platform components.", + "operational": "Operational", + "selfHostedNote": "Status reflects self-hosted deployment. Configure health checks for live monitoring.", + "components": { + "api": { + "name": "API", + "description": "REST control plane at /api/v1" + }, + "worker": { + "name": "Worker", + "description": "BullMQ background processing" + }, + "panel": { + "name": "Web panel", + "description": "Next.js user interface" + }, + "dns": { + "name": "DNS / Edge", + "description": "Join-to-start and edge gateway" + } + } + }, + "docs": { + "title": "Documentation", + "description": "Getting started, API reference, and operations guides.", + "openapi": "Interactive API documentation:", + "openapiLink": "OpenAPI / Swagger", + "cta": "Create account →", + "sections": { + "gettingStarted": { + "title": "Getting started", + "body": "Register, verify your email, create a server through the multi-step wizard, and start it from the dashboard." + }, + "api": { + "title": "API", + "body": "The REST API at /api/v1 provides endpoints for auth, server lifecycle, files, backups, and admin functions. Authentication uses session cookies." + }, + "operations": { + "title": "Operations", + "body": "Deploy control plane, worker, and node agent via Docker Compose or Ansible. See docs/operations in the repository." + }, + "whmcs": { + "title": "WHMCS", + "body": "The server module provisions services automatically. SSO enables seamless access from the WHMCS client area." + } + } + }, + "impressum": { + "title": "Legal notice", + "description": "Information according to applicable law (German Impressum).", + "notice": "This is a template legal notice. Replace placeholder details before production use.", + "sections": { + "operator": { + "title": "Service provider", + "body": "HexaHost\nGame server hosting platform" + }, + "address": { + "title": "Address", + "body": "HexaHost\nMusterstraße 1\n12345 Musterstadt\nGermany" + }, + "contact": { + "title": "Contact", + "body": "Email: kontakt@hexahost.example\nPhone: +49 (0) 123 456789" + }, + "responsible": { + "title": "Responsible for content (§ 55 Abs. 2 RStV)", + "body": "Max Mustermann\nHexaHost\nMusterstraße 1\n12345 Musterstadt" + }, + "register": { + "title": "Commercial register", + "body": "Register court: Amtsgericht Musterstadt\nRegistration number: HRB 12345\nVAT ID: DE123456789" + }, + "dispute": { + "title": "Dispute resolution", + "body": "We are not obliged or willing to participate in dispute resolution proceedings before a consumer arbitration board." + } + } + }, + "privacy": { + "title": "Privacy policy", + "description": "Information on processing personal data.", + "sections": { + "controller": { + "title": "Controller", + "body": "The operator of this self-hosted instance is responsible for data processing. Adapt this policy to your organization." + }, + "data": { + "title": "Data collected", + "body": "We process account data (email, username), session information, server metadata, and usage data for billing." + }, + "cookies": { + "title": "Cookies", + "body": "Session cookies (hgc_session) and CSRF cookies (hgc_csrf) are required for authentication." + }, + "rights": { + "title": "Your rights", + "body": "You have the right to access, rectify, delete, and port your data under applicable privacy law." + }, + "contact": { + "title": "Contact", + "body": "For privacy requests, contact the operator listed in your legal notice." + } + } + }, + "terms": { + "title": "Terms of service", + "description": "Terms of use for the platform.", + "sections": { + "scope": { + "title": "Scope", + "body": "These terms apply to use of the Minecraft server hosting platform by registered users." + }, + "usage": { + "title": "Usage", + "body": "Servers may only be operated for lawful purposes. Abuse may result in suspension." + }, + "billing": { + "title": "Billing", + "body": "Credits are consumed based on runtime. Balance is non-transferable unless otherwise agreed." + }, + "liability": { + "title": "Liability", + "body": "The operator is liable only for intent and gross negligence. Protect against data loss with your own backups." + }, + "changes": { + "title": "Changes", + "body": "The operator may change these terms with reasonable notice. Continued use constitutes acceptance." + } + } } } } diff --git a/apps/web/src/app/[locale]/(account)/billing/page.tsx b/apps/web/src/app/[locale]/(account)/billing/page.tsx new file mode 100644 index 0000000..d6e1a9c --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/billing/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { BillingContent } from "@/components/account/billing-content"; + +interface BillingPageProps { + params: Promise<{ locale: string }>; +} + +export default async function BillingPage({ params }: BillingPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/(account)/layout.tsx b/apps/web/src/app/[locale]/(account)/layout.tsx new file mode 100644 index 0000000..0f9b576 --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/layout.tsx @@ -0,0 +1,24 @@ +import { setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; +import { AccountNav } from "@/components/layout/account-nav"; +import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; + +interface AccountLayoutProps { + children: ReactNode; + params: Promise<{ locale: string }>; +} + +export default async function AccountLayout({ children, params }: AccountLayoutProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ( +
+
+ + +
+ {children} +
+ ); +} diff --git a/apps/web/src/app/[locale]/(account)/notifications/page.tsx b/apps/web/src/app/[locale]/(account)/notifications/page.tsx new file mode 100644 index 0000000..b7bf5cf --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/notifications/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { NotificationsContent } from "@/components/account/notifications-content"; + +interface NotificationsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function NotificationsPage({ params }: NotificationsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/(account)/profile/page.tsx b/apps/web/src/app/[locale]/(account)/profile/page.tsx new file mode 100644 index 0000000..7ba5f0c --- /dev/null +++ b/apps/web/src/app/[locale]/(account)/profile/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ProfileContent } from "@/components/account/profile-content"; + +interface ProfilePageProps { + params: Promise<{ locale: string }>; +} + +export default async function ProfilePage({ params }: ProfilePageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/audit/page.tsx b/apps/web/src/app/[locale]/admin/audit/page.tsx new file mode 100644 index 0000000..a816d30 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/audit/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminAuditContent } from "@/components/admin/admin-audit-content"; + +interface AdminAuditPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminAuditPage({ params }: AdminAuditPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/jobs/page.tsx b/apps/web/src/app/[locale]/admin/jobs/page.tsx new file mode 100644 index 0000000..64d40b8 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/jobs/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminJobsContent } from "@/components/admin/admin-jobs-content"; + +interface AdminJobsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminJobsPage({ params }: AdminJobsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/layout.tsx b/apps/web/src/app/[locale]/admin/layout.tsx new file mode 100644 index 0000000..2aae170 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/layout.tsx @@ -0,0 +1,34 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import type { ReactNode } from "react"; +import { AdminNav } from "@/components/layout/admin-nav"; +import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; +import { Link } from "@/i18n/navigation"; + +interface AdminLayoutProps { + children: ReactNode; + params: Promise<{ locale: string }>; +} + +export default async function AdminLayout({ children, params }: AdminLayoutProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("admin"); + + return ( +
+
+ + {t("backToPanel")} + +
+
+ + +
+ {children} +
+ ); +} diff --git a/apps/web/src/app/[locale]/admin/nodes/page.tsx b/apps/web/src/app/[locale]/admin/nodes/page.tsx new file mode 100644 index 0000000..1cd8fd3 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/nodes/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminNodesContent } from "@/components/admin/admin-nodes-content"; + +interface AdminNodesPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminNodesPage({ params }: AdminNodesPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/page.tsx b/apps/web/src/app/[locale]/admin/page.tsx new file mode 100644 index 0000000..c671bc1 --- /dev/null +++ b/apps/web/src/app/[locale]/admin/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminDashboardContent } from "@/components/admin/admin-dashboard-content"; + +interface AdminPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminPage({ params }: AdminPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/admin/users/page.tsx b/apps/web/src/app/[locale]/admin/users/page.tsx new file mode 100644 index 0000000..9a7396d --- /dev/null +++ b/apps/web/src/app/[locale]/admin/users/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { AdminUsersContent } from "@/components/admin/admin-users-content"; + +interface AdminUsersPageProps { + params: Promise<{ locale: string }>; +} + +export default async function AdminUsersPage({ params }: AdminUsersPageProps) { + const { locale } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/dashboard/layout.tsx b/apps/web/src/app/[locale]/dashboard/layout.tsx index 424f701..0b394cf 100644 --- a/apps/web/src/app/[locale]/dashboard/layout.tsx +++ b/apps/web/src/app/[locale]/dashboard/layout.tsx @@ -1,6 +1,6 @@ -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { setRequestLocale } from "next-intl/server"; import type { ReactNode } from "react"; -import { Link } from "@/i18n/navigation"; +import { AccountNav } from "@/components/layout/account-nav"; import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; interface DashboardLayoutProps { @@ -14,32 +14,11 @@ export default async function DashboardLayout({ }: DashboardLayoutProps) { const { locale } = await params; setRequestLocale(locale); - const t = await getTranslations("common"); return (
- +
{children} diff --git a/apps/web/src/app/[locale]/docs/page.tsx b/apps/web/src/app/[locale]/docs/page.tsx new file mode 100644 index 0000000..cc095e7 --- /dev/null +++ b/apps/web/src/app/[locale]/docs/page.tsx @@ -0,0 +1,48 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; +import { Link } from "@/i18n/navigation"; +import { getApiUrl } from "@/lib/env"; + +interface DocsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function DocsPage({ params }: DocsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.docs"); + const apiUrl = getApiUrl(); + + const sections = ["gettingStarted", "api", "operations", "whmcs"] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +

+ {t("openapi")}{" "} + + {t("openapiLink")} + +

+

+ + {t("cta")} + +

+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/features/page.tsx b/apps/web/src/app/[locale]/features/page.tsx new file mode 100644 index 0000000..2efc263 --- /dev/null +++ b/apps/web/src/app/[locale]/features/page.tsx @@ -0,0 +1,30 @@ +import { Card, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface FeaturesPageProps { + params: Promise<{ locale: string }>; +} + +export default async function FeaturesPage({ params }: FeaturesPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.features"); + + const items = ["control", "nodes", "security", "billing", "backups", "whmcs"] as const; + + return ( + +
+ {items.map((item) => ( + + + {t(`items.${item}.title`)} + {t(`items.${item}.description`)} + + + ))} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/impressum/page.tsx b/apps/web/src/app/[locale]/impressum/page.tsx new file mode 100644 index 0000000..f100ea7 --- /dev/null +++ b/apps/web/src/app/[locale]/impressum/page.tsx @@ -0,0 +1,37 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface ImpressumPageProps { + params: Promise<{ locale: string }>; +} + +export default async function ImpressumPage({ params }: ImpressumPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.impressum"); + + const sections = [ + "operator", + "address", + "contact", + "responsible", + "register", + "dispute", + ] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +

{t("notice")}

+
+
+ ); +} diff --git a/apps/web/src/app/[locale]/pricing/page.tsx b/apps/web/src/app/[locale]/pricing/page.tsx new file mode 100644 index 0000000..2d2237c --- /dev/null +++ b/apps/web/src/app/[locale]/pricing/page.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; +import { Link } from "@/i18n/navigation"; + +interface PricingPageProps { + params: Promise<{ locale: string }>; +} + +export default async function PricingPage({ params }: PricingPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.pricing"); + + const tiers = ["starter", "standard", "pro"] as const; + + return ( + +
+ {tiers.map((tier) => ( + + + {t(`tiers.${tier}.name`)} + {t(`tiers.${tier}.description`)} + + +

+ {t(`tiers.${tier}.price`)} +

+
    + {(t.raw(`tiers.${tier}.features`) as string[]).map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+
+ ))} +
+

+ + {t("cta")} + +

+
+ ); +} diff --git a/apps/web/src/app/[locale]/privacy/page.tsx b/apps/web/src/app/[locale]/privacy/page.tsx new file mode 100644 index 0000000..9cb6e7d --- /dev/null +++ b/apps/web/src/app/[locale]/privacy/page.tsx @@ -0,0 +1,29 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface PrivacyPageProps { + params: Promise<{ locale: string }>; +} + +export default async function PrivacyPage({ params }: PrivacyPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.privacy"); + + const sections = ["controller", "data", "cookies", "rights", "contact"] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/security/page.tsx b/apps/web/src/app/[locale]/security/page.tsx index 68ffb79..327cdec 100644 --- a/apps/web/src/app/[locale]/security/page.tsx +++ b/apps/web/src/app/[locale]/security/page.tsx @@ -1,5 +1,7 @@ import { setRequestLocale } from "next-intl/server"; import { SecurityContent } from "@/components/auth/security-content"; +import { AccountNav } from "@/components/layout/account-nav"; +import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; interface SecurityPageProps { params: Promise<{ locale: string }>; @@ -11,6 +13,10 @@ export default async function SecurityPage({ params }: SecurityPageProps) { return (
+
+ + +
); diff --git a/apps/web/src/app/[locale]/servers/[id]/access/page.tsx b/apps/web/src/app/[locale]/servers/[id]/access/page.tsx new file mode 100644 index 0000000..87ca0e9 --- /dev/null +++ b/apps/web/src/app/[locale]/servers/[id]/access/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ServerAccess } from "@/components/servers/server-access"; + +interface ServerAccessPageProps { + params: Promise<{ locale: string; id: string }>; +} + +export default async function ServerAccessPage({ params }: ServerAccessPageProps) { + const { locale, id } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx b/apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx new file mode 100644 index 0000000..58bc18f --- /dev/null +++ b/apps/web/src/app/[locale]/servers/[id]/schedules/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ServerSchedules } from "@/components/servers/server-schedules"; + +interface ServerSchedulesPageProps { + params: Promise<{ locale: string; id: string }>; +} + +export default async function ServerSchedulesPage({ params }: ServerSchedulesPageProps) { + const { locale, id } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/servers/[id]/software/page.tsx b/apps/web/src/app/[locale]/servers/[id]/software/page.tsx new file mode 100644 index 0000000..5889cd1 --- /dev/null +++ b/apps/web/src/app/[locale]/servers/[id]/software/page.tsx @@ -0,0 +1,13 @@ +import { setRequestLocale } from "next-intl/server"; +import { ServerSoftware } from "@/components/servers/server-software"; + +interface ServerSoftwarePageProps { + params: Promise<{ locale: string; id: string }>; +} + +export default async function ServerSoftwarePage({ params }: ServerSoftwarePageProps) { + const { locale, id } = await params; + setRequestLocale(locale); + + return ; +} diff --git a/apps/web/src/app/[locale]/servers/layout.tsx b/apps/web/src/app/[locale]/servers/layout.tsx index 4795dd0..4451362 100644 --- a/apps/web/src/app/[locale]/servers/layout.tsx +++ b/apps/web/src/app/[locale]/servers/layout.tsx @@ -1,6 +1,6 @@ -import { getTranslations, setRequestLocale } from "next-intl/server"; +import { setRequestLocale } from "next-intl/server"; import type { ReactNode } from "react"; -import { Link } from "@/i18n/navigation"; +import { AccountNav } from "@/components/layout/account-nav"; import { DashboardUserInfo } from "@/components/dashboard/dashboard-user-info"; interface ServersLayoutProps { @@ -11,32 +11,11 @@ interface ServersLayoutProps { export default async function ServersLayout({ children, params }: ServersLayoutProps) { const { locale } = await params; setRequestLocale(locale); - const t = await getTranslations("common"); return (
- +
{children} diff --git a/apps/web/src/app/[locale]/status/page.tsx b/apps/web/src/app/[locale]/status/page.tsx new file mode 100644 index 0000000..a8d25c6 --- /dev/null +++ b/apps/web/src/app/[locale]/status/page.tsx @@ -0,0 +1,38 @@ +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface StatusPageProps { + params: Promise<{ locale: string }>; +} + +export default async function StatusPage({ params }: StatusPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.status"); + + const components = ["api", "worker", "panel", "dns"] as const; + + return ( + +
+ {components.map((component) => ( + + +
+ {t(`components.${component}.name`)} + {t(`components.${component}.description`)} +
+ + {t("operational")} + +
+ +

{t("selfHostedNote")}

+
+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/[locale]/terms/page.tsx b/apps/web/src/app/[locale]/terms/page.tsx new file mode 100644 index 0000000..aff0f59 --- /dev/null +++ b/apps/web/src/app/[locale]/terms/page.tsx @@ -0,0 +1,29 @@ +import { getTranslations, setRequestLocale } from "next-intl/server"; +import { PublicPageShell } from "@/components/layout/public-page-shell"; + +interface TermsPageProps { + params: Promise<{ locale: string }>; +} + +export default async function TermsPage({ params }: TermsPageProps) { + const { locale } = await params; + setRequestLocale(locale); + const t = await getTranslations("public.terms"); + + const sections = ["scope", "usage", "billing", "liability", "changes"] as const; + + return ( + +
+ {sections.map((section) => ( +
+

+ {t(`sections.${section}.title`)} +

+

{t(`sections.${section}.body`)}

+
+ ))} +
+
+ ); +} diff --git a/apps/web/src/components/account/billing-content.tsx b/apps/web/src/components/account/billing-content.tsx new file mode 100644 index 0000000..0891c54 --- /dev/null +++ b/apps/web/src/components/account/billing-content.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { getWallet } from "@/lib/api/billing"; + +export function BillingContent() { + const t = useTranslations("account.billing"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["wallet"], + queryFn: getWallet, + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError || !data) { + return

{t("loadError")}

; + } + + const transactions = data.transactions ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + + + {t("balanceTitle")} + + {t("periodStart", { + date: new Date(data.periodStart).toLocaleDateString(), + })} + + + +

+ {data.balance} +

+

{t("creditsLabel")}

+
+
+ +
+

+ {t("transactionsTitle")} +

+ {transactions.length === 0 ? ( +

{t("transactionsEmpty")}

+ ) : ( +
+ + + + + + + + + + + {transactions.map((tx) => ( + + + + + + + ))} + +
{t("columns.date")}{t("columns.type")}{t("columns.amount")}{t("columns.reference")}
+ {new Date(tx.createdAt).toLocaleString()} + {tx.type}{tx.amount} + {tx.referenceType ?? "—"} +
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/account/notifications-content.tsx b/apps/web/src/components/account/notifications-content.tsx new file mode 100644 index 0000000..75df98b --- /dev/null +++ b/apps/web/src/components/account/notifications-content.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + getNotificationPreferences, + listNotifications, + markAllNotificationsRead, + markNotificationRead, + updateNotificationPreferences, + type Notification, + type NotificationPreference, +} from "@/lib/api/notifications"; + +function formatDate(value: string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(value)); +} + +export function NotificationsContent() { + const t = useTranslations("account.notifications"); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["notifications"], + queryFn: listNotifications, + }); + + const { data: preferencesData } = useQuery({ + queryKey: ["notification-preferences"], + queryFn: getNotificationPreferences, + }); + + const invalidate = () => { + void queryClient.invalidateQueries({ queryKey: ["notifications"] }); + void queryClient.invalidateQueries({ queryKey: ["notification-preferences"] }); + }; + + const markReadMutation = useMutation({ + mutationFn: markNotificationRead, + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.markReadFailed")); + }, + }); + + const markAllReadMutation = useMutation({ + mutationFn: markAllNotificationsRead, + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.markReadFailed")); + }, + }); + + const preferencesMutation = useMutation({ + mutationFn: (preferences: NotificationPreference[]) => + updateNotificationPreferences(preferences), + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError( + err instanceof ApiError ? (err.detail ?? err.title) : t("errors.preferencesFailed"), + ); + }, + }); + + function togglePreference(pref: NotificationPreference) { + const current = preferencesData?.preferences ?? []; + const next = current.some( + (p) => p.type === pref.type && p.channel === pref.channel, + ) + ? current.map((p) => + p.type === pref.type && p.channel === pref.channel + ? { ...p, enabled: !p.enabled } + : p, + ) + : [...current, { ...pref, enabled: false }]; + preferencesMutation.mutate(next); + } + + const notifications = data?.notifications ?? []; + const unreadCount = data?.unreadCount ?? 0; + + return ( +
+
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ {unreadCount > 0 ? ( + + ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + + {t("listTitle")} + + {unreadCount > 0 ? t("unreadCount", { count: unreadCount }) : t("allRead")} + + + + {isLoading ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("errors.loadFailed")}

+ ) : notifications.length === 0 ? ( +
+

{t("emptyTitle")}

+

+ {t("emptyDescription")} +

+
+ ) : ( +
    + {notifications.map((notification: Notification) => ( +
  • +
    +

    + {notification.title} +

    +

    + {notification.body} +

    +

    + {formatDate(notification.createdAt)} · {t(`types.${notification.type}`)} +

    +
    + {!notification.readAt ? ( + + ) : null} +
  • + ))} +
+ )} +
+
+ + {(preferencesData?.preferences ?? []).length > 0 ? ( + + + {t("preferencesTitle")} + {t("preferencesDescription")} + + +
    + {preferencesData!.preferences.map((pref) => ( +
  • +
    +

    + {t(`types.${pref.type}`)} +

    +

    {t(`channels.${pref.channel}`)}

    +
    + +
  • + ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/account/profile-content.tsx b/apps/web/src/components/account/profile-content.tsx new file mode 100644 index 0000000..7953fd5 --- /dev/null +++ b/apps/web/src/components/account/profile-content.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; +import { useAuth } from "@/components/providers/auth-provider"; + +export function ProfileContent() { + const t = useTranslations("account.profile"); + const { user, isLoading } = useAuth(); + + if (isLoading) { + return

{t("loading")}

; + } + + if (!user) { + return

{t("notSignedIn")}

; + } + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + + + {t("accountInfo")} + {t("accountInfoDescription")} + + +
+ {t("fields.email")} + {user.email} +
+
+ {t("fields.username")} + {user.username} +
+ {user.displayName ? ( +
+ {t("fields.displayName")} + {user.displayName} +
+ ) : null} +
+ {t("fields.emailVerified")} + + {user.emailVerified || user.emailVerifiedAt ? t("yes") : t("no")} + +
+
+ {t("fields.twoFactor")} + + {user.twoFactorEnabled ? t("enabled") : t("disabled")} + +
+
+
+ +

+ {t("securityHint")}{" "} + + {t("securityLink")} + +

+
+ ); +} diff --git a/apps/web/src/components/admin/admin-audit-content.tsx b/apps/web/src/components/admin/admin-audit-content.tsx new file mode 100644 index 0000000..6346933 --- /dev/null +++ b/apps/web/src/components/admin/admin-audit-content.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Button } from "@hexahost/ui"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { listAdminAuditEvents } from "@/lib/api/admin"; + +export function AdminAuditContent() { + const t = useTranslations("admin.audit"); + const [cursor, setCursor] = useState(undefined); + const [allItems, setAllItems] = useState< + Awaited>["items"] + >([]); + + const { data, isLoading, isError, isFetching } = useQuery({ + queryKey: ["admin", "audit", cursor], + queryFn: async () => { + const result = await listAdminAuditEvents({ limit: 50, cursor }); + if (!cursor) { + setAllItems(result.items); + } else { + setAllItems((prev) => [...prev, ...result.items]); + } + return result; + }, + }); + + const items = cursor ? allItems : (data?.items ?? []); + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {isLoading && items.length === 0 ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("loadError")}

+ ) : items.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + {items.map((event) => ( + + + + + + + ))} + +
{t("columns.time")}{t("columns.action")}{t("columns.user")}{t("columns.entity")}
+ {new Date(event.createdAt).toLocaleString()} + {event.action} + {event.user?.email ?? "—"} + + {event.entityType ?? "—"} + {event.entityId ? ` · ${event.entityId.slice(0, 8)}…` : ""} +
+
+ )} + + {data?.nextCursor ? ( + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/admin/admin-dashboard-content.tsx b/apps/web/src/components/admin/admin-dashboard-content.tsx new file mode 100644 index 0000000..d786c20 --- /dev/null +++ b/apps/web/src/components/admin/admin-dashboard-content.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Card, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { getAdminDashboardStats } from "@/lib/api/admin"; + +export function AdminDashboardContent() { + const t = useTranslations("admin.dashboard"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "dashboard"], + queryFn: getAdminDashboardStats, + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError || !data) { + return ( +

{t("loadError")}

+ ); + } + + const stats = [ + { key: "activeUsers", value: data.activeUsers }, + { key: "totalServers", value: data.totalServers }, + { key: "runningServers", value: data.runningServers }, + { key: "queueLength", value: data.queueLength }, + { key: "onlineNodes", value: data.onlineNodes }, + { key: "failedJobs24h", value: data.failedJobs24h }, + { key: "openAbuseReports", value: data.openAbuseReports }, + { key: "openTickets", value: data.openTickets }, + ] as const; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
+ {stats.map((stat) => ( + + + {t(`stats.${stat.key}`)} + {stat.value} + + + ))} +
+ +

+ {t("generatedAt", { time: new Date(data.generatedAt).toLocaleString() })} +

+
+ ); +} diff --git a/apps/web/src/components/admin/admin-jobs-content.tsx b/apps/web/src/components/admin/admin-jobs-content.tsx new file mode 100644 index 0000000..bcb1a52 --- /dev/null +++ b/apps/web/src/components/admin/admin-jobs-content.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { listAdminJobs } from "@/lib/api/admin"; + +export function AdminJobsContent() { + const t = useTranslations("admin.jobs"); + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "jobs"], + queryFn: () => listAdminJobs({ limit: 50 }), + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError) { + return

{t("loadError")}

; + } + + const jobs = data?.jobs ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {jobs.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + + {jobs.map((job) => ( + + + + + + + + ))} + +
{t("columns.queue")}{t("columns.job")}{t("columns.status")}{t("columns.attempts")}{t("columns.created")}
{job.queue}{job.jobName}{job.status}{job.attempts} + {new Date(job.createdAt).toLocaleString()} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/admin/admin-nodes-content.tsx b/apps/web/src/components/admin/admin-nodes-content.tsx new file mode 100644 index 0000000..79ac618 --- /dev/null +++ b/apps/web/src/components/admin/admin-nodes-content.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { Button } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { + drainAdminNode, + listAdminNodes, + setAdminNodeMaintenance, +} from "@/lib/api/admin"; +import { ApiError } from "@/lib/api/auth"; + +export function AdminNodesContent() { + const t = useTranslations("admin.nodes"); + const queryClient = useQueryClient(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["admin", "nodes"], + queryFn: listAdminNodes, + }); + + const drainMutation = useMutation({ + mutationFn: drainAdminNode, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "nodes"] }), + }); + + const maintenanceMutation = useMutation({ + mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => + setAdminNodeMaintenance(id, enabled), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "nodes"] }), + }); + + if (isLoading) { + return

{t("loading")}

; + } + + if (isError) { + return

{t("loadError")}

; + } + + const nodes = data?.nodes ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ + {nodes.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + + {nodes.map((node) => ( + + + + + + + + ))} + +
{t("columns.name")}{t("columns.status")}{t("columns.servers")}{t("columns.ram")}{t("columns.actions")}
+

{node.name}

+

{node.hostname}

+
{node.status} + {node.activeServers} / {node.maxServers} + + {node.availableRamMb} / {node.maxRamMb} MB + +
+ + +
+
+
+ )} + + {(drainMutation.isError || maintenanceMutation.isError) && ( +

+ {drainMutation.error instanceof ApiError + ? (drainMutation.error.detail ?? drainMutation.error.title) + : maintenanceMutation.error instanceof ApiError + ? (maintenanceMutation.error.detail ?? maintenanceMutation.error.title) + : t("actionError")} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/admin/admin-users-content.tsx b/apps/web/src/components/admin/admin-users-content.tsx new file mode 100644 index 0000000..4684c52 --- /dev/null +++ b/apps/web/src/components/admin/admin-users-content.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { Button } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { searchAdminUsers, suspendAdminUser, unsuspendAdminUser } from "@/lib/api/admin"; +import { ApiError } from "@/lib/api/auth"; + +export function AdminUsersContent() { + const t = useTranslations("admin.users"); + const queryClient = useQueryClient(); + const [query, setQuery] = useState(""); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["admin", "users", query], + queryFn: () => searchAdminUsers({ q: query || undefined, limit: 50 }), + }); + + const suspendMutation = useMutation({ + mutationFn: suspendAdminUser, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }), + }); + + const unsuspendMutation = useMutation({ + mutationFn: unsuspendAdminUser, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["admin", "users"] }), + }); + + const users = data?.users ?? []; + + return ( +
+
+

+ {t("title")} +

+

{t("subtitle")}

+
+ +
{ + event.preventDefault(); + void refetch(); + }} + > + setQuery(event.target.value)} + placeholder={t("searchPlaceholder")} + className="flex h-10 flex-1 rounded-md border border-zinc-300 bg-white px-3 text-sm dark:border-zinc-700 dark:bg-zinc-950" + /> + +
+ + {isLoading ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("loadError")}

+ ) : users.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ + + + + + + + + + + + {users.map((user) => ( + + + + + + + + ))} + +
{t("columns.user")}{t("columns.status")}{t("columns.roles")}{t("columns.servers")}{t("columns.actions")}
+

+ {user.displayName ?? user.username} +

+

{user.email}

+
{user.status}{user.roles.join(", ")}{user.serverCount} + {user.status === "SUSPENDED" ? ( + + ) : ( + + )} +
+
+ )} + + {(suspendMutation.isError || unsuspendMutation.isError) && ( +

+ {suspendMutation.error instanceof ApiError + ? (suspendMutation.error.detail ?? suspendMutation.error.title) + : unsuspendMutation.error instanceof ApiError + ? (unsuspendMutation.error.detail ?? unsuspendMutation.error.title) + : t("actionError")} +

+ )} +
+ ); +} diff --git a/apps/web/src/components/dashboard/dashboard-user-info.tsx b/apps/web/src/components/dashboard/dashboard-user-info.tsx index f541df3..b20b656 100644 --- a/apps/web/src/components/dashboard/dashboard-user-info.tsx +++ b/apps/web/src/components/dashboard/dashboard-user-info.tsx @@ -33,6 +33,14 @@ export function DashboardUserInfo() {

{user.email}

+ {user.roles?.includes("SUPER_ADMIN") ? ( + + {t("admin")} + + ) : null} +
    + {links.map((link) => { + const active = + pathname === link.href || pathname.startsWith(`${link.href}/`); + + return ( +
  • + + {t(link.key)} + +
  • + ); + })} +
+ + ); +} diff --git a/apps/web/src/components/layout/admin-nav.tsx b/apps/web/src/components/layout/admin-nav.tsx new file mode 100644 index 0000000..1be36f9 --- /dev/null +++ b/apps/web/src/components/layout/admin-nav.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Link, usePathname } from "@/i18n/navigation"; + +const links = [ + { key: "dashboard", href: "/admin" }, + { key: "nodes", href: "/admin/nodes" }, + { key: "users", href: "/admin/users" }, + { key: "jobs", href: "/admin/jobs" }, + { key: "audit", href: "/admin/audit" }, +] as const; + +export function AdminNav() { + const t = useTranslations("admin.nav"); + const pathname = usePathname(); + + function isActive(href: string): boolean { + if (href === "/admin") { + return pathname === "/admin"; + } + return pathname === href || pathname.startsWith(`${href}/`); + } + + return ( + + ); +} diff --git a/apps/web/src/components/layout/footer.tsx b/apps/web/src/components/layout/footer.tsx index c336c9d..885d6d9 100644 --- a/apps/web/src/components/layout/footer.tsx +++ b/apps/web/src/components/layout/footer.tsx @@ -1,6 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; +import { Link } from "@/i18n/navigation"; import { getAppName } from "@/lib/env"; export function Footer() { @@ -10,13 +11,27 @@ export function Footer() { return (
-
-

- © {year} {appName}. {t("footer")} -

-

- Phase 0 · Frontend Foundation -

+
+ +
+

+ © {year} {appName}. {t("footer")} +

+

{t("phaseBadge")}

+
); diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 44d7b5b..e9f7408 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -24,6 +24,24 @@ export function Header() { className="flex items-center gap-2 sm:gap-4" aria-label="Main navigation" > + + {t("features")} + + + {t("pricing")} + + + {t("docs")} + +
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} +
+
+ {children} +
+
+ ); +} diff --git a/apps/web/src/components/providers/auth-provider.tsx b/apps/web/src/components/providers/auth-provider.tsx index f641e03..72b5bd1 100644 --- a/apps/web/src/components/providers/auth-provider.tsx +++ b/apps/web/src/components/providers/auth-provider.tsx @@ -10,6 +10,7 @@ import { type ReactNode, } from "react"; import { + clearCsrfToken, getCurrentUser, submitLogout, type CurrentUser, @@ -54,6 +55,7 @@ export function AuthProvider({ children }: AuthProviderProps) { try { await submitLogout(); } finally { + clearCsrfToken(); setUser(null); router.push("/login"); } diff --git a/apps/web/src/components/servers/create-server-form.tsx b/apps/web/src/components/servers/create-server-form.tsx index bebad50..c1454bb 100644 --- a/apps/web/src/components/servers/create-server-form.tsx +++ b/apps/web/src/components/servers/create-server-form.tsx @@ -1,212 +1 @@ -"use client"; - -import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { useTranslations } from "next-intl"; -import { useState } from "react"; -import { useForm } from "react-hook-form"; -import { Link, useRouter } from "@/i18n/navigation"; -import { ApiError } from "@/lib/api/auth"; -import { createServer, listPlans } from "@/lib/api/servers"; -import { - createServerSchema, - MINECRAFT_VERSIONS, - SOFTWARE_FAMILIES, - type CreateServerFormValues, -} from "@/lib/schemas/server"; - -const inputClassName = - "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; - -export function CreateServerForm() { - const t = useTranslations("servers"); - const tv = useTranslations("validation"); - const router = useRouter(); - const [apiError, setApiError] = useState(null); - - const { data: plansData, isLoading: plansLoading } = useQuery({ - queryKey: ["plans"], - queryFn: listPlans, - retry: false, - }); - - const schema = createServerSchema({ - nameRequired: tv("serverNameRequired"), - nameMax: tv("serverNameMax"), - planRequired: tv("planRequired"), - planInvalid: tv("planInvalid"), - versionRequired: tv("versionRequired"), - softwareRequired: tv("softwareRequired"), - eulaRequired: tv("eulaRequired"), - }); - - const { - register, - handleSubmit, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - name: "", - planId: "", - minecraftVersion: MINECRAFT_VERSIONS[0], - softwareFamily: "VANILLA", - eulaAccepted: false, - }, - }); - - const createMutation = useMutation({ - mutationFn: createServer, - onSuccess: (server) => { - router.push(`/servers/${server.id}`); - }, - onError: (error: unknown) => { - setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic")); - }, - }); - - async function onSubmit(values: CreateServerFormValues) { - setApiError(null); - await createMutation.mutateAsync({ - ...values, - eulaAccepted: true, - }); - } - - const plans = plansData?.plans ?? []; - - return ( - - - {t("createTitle")} - {t("createDescription")} - - -
-
- - - {errors.name ? ( -

{errors.name.message}

- ) : null} -
- -
- - - {errors.planId ? ( -

{errors.planId.message}

- ) : null} - {!plansLoading && plans.length === 0 ? ( -

{t("noPlans")}

- ) : null} -
- -
- - - {errors.softwareFamily ? ( -

- {errors.softwareFamily.message} -

- ) : null} -
- -
- - - {errors.minecraftVersion ? ( -

- {errors.minecraftVersion.message} -

- ) : null} -
- -
- - {errors.eulaAccepted ? ( -

{errors.eulaAccepted.message}

- ) : null} -
- - {apiError ? ( -

- {apiError} -

- ) : null} - -
- - - {t("cancel")} - -
-
-
-
- ); -} +export { CreateServerWizard as CreateServerForm } from "./create-server-wizard"; diff --git a/apps/web/src/components/servers/create-server-wizard.tsx b/apps/web/src/components/servers/create-server-wizard.tsx new file mode 100644 index 0000000..1bf0ccf --- /dev/null +++ b/apps/web/src/components/servers/create-server-wizard.tsx @@ -0,0 +1,290 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { Link, useRouter } from "@/i18n/navigation"; +import { ApiError } from "@/lib/api/auth"; +import { createServer, listPlans } from "@/lib/api/servers"; +import { + createServerSchema, + MINECRAFT_VERSIONS, + SOFTWARE_FAMILIES, + type CreateServerFormValues, +} from "@/lib/schemas/server"; + +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const STEPS = ["basics", "software", "review"] as const; +type WizardStep = (typeof STEPS)[number]; + +export function CreateServerWizard() { + const t = useTranslations("servers"); + const tw = useTranslations("servers.wizard"); + const tv = useTranslations("validation"); + const router = useRouter(); + const [step, setStep] = useState("basics"); + const [apiError, setApiError] = useState(null); + + const { data: plansData, isLoading: plansLoading } = useQuery({ + queryKey: ["plans"], + queryFn: listPlans, + retry: false, + }); + + const schema = createServerSchema({ + nameRequired: tv("serverNameRequired"), + nameMax: tv("serverNameMax"), + planRequired: tv("planRequired"), + planInvalid: tv("planInvalid"), + versionRequired: tv("versionRequired"), + softwareRequired: tv("softwareRequired"), + eulaRequired: tv("eulaRequired"), + }); + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + planId: "", + minecraftVersion: MINECRAFT_VERSIONS[0], + softwareFamily: "VANILLA", + eulaAccepted: false, + }, + mode: "onChange", + }); + + const { + register, + handleSubmit, + trigger, + watch, + formState: { errors, isSubmitting }, + } = form; + + const createMutation = useMutation({ + mutationFn: createServer, + onSuccess: (server) => { + router.push(`/servers/${server.id}`); + }, + onError: (error: unknown) => { + setApiError(error instanceof ApiError ? (error.detail ?? error.title) : t("errors.generic")); + }, + }); + + const plans = plansData?.plans ?? []; + const values = watch(); + const stepIndex = STEPS.indexOf(step); + + async function goNext() { + setApiError(null); + if (step === "basics") { + const valid = await trigger(["name", "planId"]); + if (valid) setStep("software"); + return; + } + if (step === "software") { + const valid = await trigger(["softwareFamily", "minecraftVersion"]); + if (valid) setStep("review"); + } + } + + function goBack() { + setApiError(null); + if (step === "software") setStep("basics"); + if (step === "review") setStep("software"); + } + + async function onSubmit(formValues: CreateServerFormValues) { + setApiError(null); + await createMutation.mutateAsync({ + ...formValues, + eulaAccepted: true, + }); + } + + const selectedPlan = plans.find((plan) => plan.id === values.planId); + + return ( + + + {t("createTitle")} + {t("createDescription")} +
    + {STEPS.map((stepKey, index) => ( +
  1. + {tw(`steps.${stepKey}`)} +
  2. + ))} +
+
+ +
+ {step === "basics" ? ( + <> +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : null} +
+
+ + + {errors.planId ? ( +

{errors.planId.message}

+ ) : null} + {!plansLoading && plans.length === 0 ? ( +

{t("noPlans")}

+ ) : null} +
+ + ) : null} + + {step === "software" ? ( + <> +
+ + + {errors.softwareFamily ? ( +

+ {errors.softwareFamily.message} +

+ ) : null} +
+
+ + + {errors.minecraftVersion ? ( +

+ {errors.minecraftVersion.message} +

+ ) : null} +
+ + ) : null} + + {step === "review" ? ( + <> +
+
+
{t("name")}
+
{values.name}
+
+
+
{t("plan")}
+
+ {selectedPlan?.name ?? "—"} +
+
+
+
{t("software")}
+
+ {t(`softwareOptions.${values.softwareFamily}`)} +
+
+
+
{t("version")}
+
+ {values.minecraftVersion} +
+
+
+
+ + {errors.eulaAccepted ? ( +

+ {errors.eulaAccepted.message} +

+ ) : null} +
+ + ) : null} + + {apiError ? ( +

+ {apiError} +

+ ) : null} + +
+ {step !== "basics" ? ( + + ) : null} + {step !== "review" ? ( + + ) : ( + + )} + + {t("cancel")} + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/servers/server-access.tsx b/apps/web/src/components/servers/server-access.tsx new file mode 100644 index 0000000..17787b1 --- /dev/null +++ b/apps/web/src/components/servers/server-access.tsx @@ -0,0 +1,249 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + inviteMember, + listMembers, + removeMember, + type MemberRole, + type ServerMember, +} from "@/lib/api/members"; + +interface ServerAccessProps { + serverId: string; +} + +const INVITE_ROLES: MemberRole[] = ["ADMIN", "OPERATOR", "DEVELOPER", "VIEWER"]; + +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300"; + +function memberDisplayName(member: ServerMember): string { + return member.displayName ?? member.username; +} + +export function ServerAccess({ serverId }: ServerAccessProps) { + const t = useTranslations("servers.access"); + const queryClient = useQueryClient(); + const [showInviteForm, setShowInviteForm] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("VIEWER"); + const [error, setError] = useState(null); + const [info, setInfo] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["server-members", serverId], + queryFn: () => listMembers(serverId), + }); + + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: ["server-members", serverId] }); + + const inviteMutation = useMutation({ + mutationFn: () => inviteMember(serverId, { email: email.trim(), role }), + onSuccess: (response) => { + setError(null); + if (response.status === "pending") { + setInfo(t("invitePending")); + } else { + setInfo(t("inviteSuccess")); + setShowInviteForm(false); + setEmail(""); + setRole("VIEWER"); + invalidate(); + } + }, + onError: (err: unknown) => { + setInfo(null); + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.inviteFailed")); + }, + }); + + const removeMutation = useMutation({ + mutationFn: (memberId: string) => removeMember(serverId, memberId), + onSuccess: () => { + setError(null); + setInfo(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.removeFailed")); + }, + }); + + function startInvite() { + setShowInviteForm(true); + setError(null); + setInfo(null); + } + + function cancelInvite() { + setShowInviteForm(false); + setEmail(""); + setRole("VIEWER"); + setError(null); + setInfo(null); + } + + function handleRemove(member: ServerMember) { + const name = memberDisplayName(member); + if (!window.confirm(t("removeConfirm", { name }))) { + return; + } + removeMutation.mutate(member.id); + } + + const members = data?.members ?? []; + + return ( + + +
+
+ {t("title")} + {t("description")} +
+ {!showInviteForm ? ( + + ) : null} +
+
+ + {error ? ( +

+ {error} +

+ ) : null} + {info ? ( +

+ {info} +

+ ) : null} + + {showInviteForm ? ( +
+
+
+ + setEmail(e.target.value)} + placeholder={t("fields.emailPlaceholder")} + /> +
+
+ + +
+
+
+ + +
+
+ ) : null} + + {isLoading ? ( +

{t("loading")}

+ ) : isError ? ( +

{t("errors.loadFailed")}

+ ) : members.length === 0 && !showInviteForm ? ( +

{t("empty")}

+ ) : members.length > 0 ? ( +
+ + + + + + + + + + + + {members.map((member) => ( + + + + + + + + ))} + +
+ {t("columns.member")} + + {t("columns.email")} + + {t("columns.role")} + + {t("columns.status")} + + {t("columns.actions")} +
+ {memberDisplayName(member)} + {member.email} + {t(`roles.${member.role}`)} + + + {member.acceptedAt ? t("status.active") : t("status.pending")} + + +
+ +
+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/servers/server-files.tsx b/apps/web/src/components/servers/server-files.tsx index 3bd8d86..1645d05 100644 --- a/apps/web/src/components/servers/server-files.tsx +++ b/apps/web/src/components/servers/server-files.tsx @@ -3,9 +3,17 @@ import { Button } from "@hexahost/ui"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ApiError } from "@/lib/api/auth"; -import { listFiles, readFile, writeFile } from "@/lib/api/files"; +import { + archiveFiles, + deleteFile, + listFiles, + readFile, + unarchiveFile, + uploadFile, + writeFile, +} from "@/lib/api/files"; import { getServer } from "@/lib/api/servers"; interface ServerFilesProps { @@ -15,14 +23,35 @@ interface ServerFilesProps { const textareaClassName = "min-h-80 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 font-mono text-sm text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; +function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + const base64 = result.split(",")[1]; + if (!base64) { + reject(new Error("Failed to encode file")); + return; + } + resolve(base64); + }; + reader.onerror = () => reject(reader.error ?? new Error("Failed to read file")); + reader.readAsDataURL(file); + }); +} + export function ServerFiles({ serverId }: ServerFilesProps) { const t = useTranslations("servers.files"); const queryClient = useQueryClient(); + const fileInputRef = useRef(null); const [currentPath, setCurrentPath] = useState(""); const [selectedFile, setSelectedFile] = useState(null); + const [selectedPaths, setSelectedPaths] = useState([]); const [editorContent, setEditorContent] = useState(""); const [fileVersion, setFileVersion] = useState(); const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [actionSuccess, setActionSuccess] = useState(null); const [saveSuccess, setSaveSuccess] = useState(false); const { data: server } = useQuery({ @@ -32,6 +61,10 @@ export function ServerFiles({ serverId }: ServerFilesProps) { const canAccessFiles = server?.status === "RUNNING" || server?.status === "STOPPED"; + const isReadOnly = server?.status === "STOPPED"; + + const invalidateListing = () => + void queryClient.invalidateQueries({ queryKey: ["server-files", serverId, currentPath] }); const { data: listing, @@ -60,6 +93,10 @@ export function ServerFiles({ serverId }: ServerFilesProps) { } }, [fileData]); + useEffect(() => { + setSelectedPaths([]); + }, [currentPath]); + const saveMutation = useMutation({ mutationFn: () => writeFile(serverId, { @@ -80,6 +117,75 @@ export function ServerFiles({ serverId }: ServerFilesProps) { }, }); + const deleteMutation = useMutation({ + mutationFn: (path: string) => deleteFile(serverId, path), + onSuccess: () => { + setActionError(null); + setActionSuccess(t("deleted")); + if (selectedFile) { + setSelectedFile(null); + setEditorContent(""); + } + invalidateListing(); + setSelectedPaths([]); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.deleteFailed")); + }, + }); + + const uploadMutation = useMutation({ + mutationFn: async (file: File) => { + const content = await readFileAsBase64(file); + const targetPath = currentPath ? `${currentPath}/${file.name}` : file.name; + return uploadFile(serverId, { path: targetPath, content }); + }, + onSuccess: () => { + setActionError(null); + setActionSuccess(t("uploaded")); + invalidateListing(); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.uploadFailed")); + }, + }); + + const archiveMutation = useMutation({ + mutationFn: (paths: string[]) => archiveFiles(serverId, { paths }), + onSuccess: () => { + setActionError(null); + setActionSuccess(t("archived")); + invalidateListing(); + setSelectedPaths([]); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.archiveFailed")); + }, + }); + + const unarchiveMutation = useMutation({ + mutationFn: (archivePath: string) => + unarchiveFile(serverId, { archivePath, destinationPath: currentPath || "." }), + onSuccess: () => { + setActionError(null); + setActionSuccess(t("unarchived")); + invalidateListing(); + setTimeout(() => setActionSuccess(null), 2000); + }, + onError: (err: unknown) => { + setActionSuccess(null); + setActionError( + err instanceof ApiError ? (err.detail ?? err.title) : t("errors.unarchiveFailed"), + ); + }, + }); + function handleSelectEntry(path: string, isDir: boolean) { setError(null); setSaveSuccess(false); @@ -106,6 +212,46 @@ export function ServerFiles({ serverId }: ServerFilesProps) { setError(null); } + function togglePathSelection(path: string) { + setSelectedPaths((prev) => + prev.includes(path) ? prev.filter((p) => p !== path) : [...prev, path], + ); + } + + function handleDelete(path: string) { + if (!window.confirm(t("deleteConfirm", { name: path.split("/").pop() ?? path }))) { + return; + } + deleteMutation.mutate(path); + } + + function handleArchiveSelected() { + if (selectedPaths.length === 0) { + return; + } + archiveMutation.mutate(selectedPaths); + } + + function handleUnarchive() { + const archivePath = selectedFile ?? selectedPaths[0]; + if (!archivePath) { + return; + } + if (!window.confirm(t("unarchiveConfirm", { name: archivePath.split("/").pop() ?? archivePath }))) { + return; + } + unarchiveMutation.mutate(archivePath); + } + + async function handleUploadChange(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file || isReadOnly) { + return; + } + uploadMutation.mutate(file); + } + if (!server) { return

{t("loading")}

; } @@ -118,18 +264,52 @@ export function ServerFiles({ serverId }: ServerFilesProps) { ); } - const isReadOnly = server.status === "STOPPED"; + const busy = + deleteMutation.isPending || + uploadMutation.isPending || + archiveMutation.isPending || + unarchiveMutation.isPending; return (
-
+

{t("browser")}

- {currentPath ? ( - - ) : null} +
+ {!isReadOnly ? ( + <> + void handleUploadChange(event)} + /> + + + ) : null} + {selectedPaths.length > 0 && !isReadOnly ? ( + + ) : null} + {(selectedFile ?? selectedPaths[0])?.endsWith(".tar.gz") && !isReadOnly ? ( + + ) : null} + {currentPath ? ( + + ) : null} +

@@ -140,6 +320,15 @@ export function ServerFiles({ serverId }: ServerFilesProps) {

{t("readOnlyStopped")}

) : null} + {actionError ? ( +

+ {actionError} +

+ ) : null} + {actionSuccess ? ( +

{actionSuccess}

+ ) : null} + {listingLoading ? (

{t("loading")}

) : listingError ? ( @@ -150,11 +339,20 @@ export function ServerFiles({ serverId }: ServerFilesProps) {
  • {t("empty")}
  • ) : ( (listing?.entries ?? []).map((entry) => ( -
  • +
  • + {!isReadOnly ? ( + togglePathSelection(entry.path)} + aria-label={entry.name} + /> + ) : null} + {!isReadOnly ? ( + + ) : null}
  • )) )} @@ -212,10 +421,7 @@ export function ServerFiles({ serverId }: ServerFilesProps) { ) : null} {!isReadOnly ? ( - ) : null} diff --git a/apps/web/src/components/servers/server-players.tsx b/apps/web/src/components/servers/server-players.tsx index 0007448..42ef8d7 100644 --- a/apps/web/src/components/servers/server-players.tsx +++ b/apps/web/src/components/servers/server-players.tsx @@ -1,15 +1,40 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { Button } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; -import { getPlayers } from "@/lib/api/players"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + addBan, + addOperator, + addWhitelist, + getPlayers, + kickPlayer, + removeBan, + removeOperator, + removeWhitelist, +} from "@/lib/api/players"; interface ServerPlayersProps { serverId: string; } +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300"; + export function ServerPlayers({ serverId }: ServerPlayersProps) { const t = useTranslations("servers.players"); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [whitelistName, setWhitelistName] = useState(""); + const [whitelistUuid, setWhitelistUuid] = useState(""); + const [operatorName, setOperatorName] = useState(""); + const [operatorUuid, setOperatorUuid] = useState(""); + const [banName, setBanName] = useState(""); + const [banReason, setBanReason] = useState(""); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["server-players", serverId], @@ -17,6 +42,75 @@ export function ServerPlayers({ serverId }: ServerPlayersProps) { refetchInterval: 15000, }); + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: ["server-players", serverId] }); + + const mutationOptions = { + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.actionFailed")); + }, + }; + + const addWhitelistMutation = useMutation({ + mutationFn: () => + addWhitelist(serverId, { name: whitelistName.trim(), uuid: whitelistUuid.trim() }), + ...mutationOptions, + onSuccess: () => { + mutationOptions.onSuccess(); + setWhitelistName(""); + setWhitelistUuid(""); + }, + }); + + const removeWhitelistMutation = useMutation({ + mutationFn: (uuid: string) => removeWhitelist(serverId, { uuid }), + ...mutationOptions, + }); + + const addOperatorMutation = useMutation({ + mutationFn: () => + addOperator(serverId, { name: operatorName.trim(), uuid: operatorUuid.trim() }), + ...mutationOptions, + onSuccess: () => { + mutationOptions.onSuccess(); + setOperatorName(""); + setOperatorUuid(""); + }, + }); + + const removeOperatorMutation = useMutation({ + mutationFn: (uuid: string) => removeOperator(serverId, { uuid }), + ...mutationOptions, + }); + + const addBanMutation = useMutation({ + mutationFn: () => + addBan(serverId, { + name: banName.trim(), + reason: banReason.trim() || undefined, + }), + ...mutationOptions, + onSuccess: () => { + mutationOptions.onSuccess(); + setBanName(""); + setBanReason(""); + }, + }); + + const removeBanMutation = useMutation({ + mutationFn: (name: string) => removeBan(serverId, { name }), + ...mutationOptions, + }); + + const kickMutation = useMutation({ + mutationFn: (name: string) => kickPlayer(serverId, { name }), + ...mutationOptions, + }); + if (isLoading) { return

    {t("loading")}

    ; } @@ -36,61 +130,257 @@ export function ServerPlayers({ serverId }: ServerPlayersProps) { ); } + const busy = + addWhitelistMutation.isPending || + removeWhitelistMutation.isPending || + addOperatorMutation.isPending || + removeOperatorMutation.isPending || + addBanMutation.isPending || + removeBanMutation.isPending || + kickMutation.isPending; + return ( -
    +
    + {error ? ( +

    + {error} +

    + ) : null} +

    {t("onlineCount")}

    {data.online.length} - / {data.maxPlayers}

    {t("whitelistCount")}

    - {data.whitelistCount} + {data.whitelist.length} +

    +
    +
    +

    {t("operatorsCount")}

    +

    + {data.operators.length}

    -
    - - - - - - - - - {data.online.length === 0 ? ( +
    +

    {t("onlineTitle")}

    +
    +
    - {t("columns.name")} - - {t("columns.ping")} -
    + - + + - ) : ( - data.online.map((player) => ( - - - + + {data.online.length === 0 ? ( + + - )) - )} - -
    - {t("empty")} - + {t("columns.name")} + + {t("columns.actions")} +
    - {player.name} - - {player.ping !== undefined ? `${player.ping} ms` : "—"} +
    + {t("empty")}
    -
    + ) : ( + data.online.map((player) => ( + + + {player.name} + + + + + + )) + )} + + +
    + + +
    +

    {t("whitelistTitle")}

    +
    +
    + + setWhitelistName(e.target.value)} + /> +
    +
    + + setWhitelistUuid(e.target.value)} + /> +
    +
    + + removeWhitelistMutation.mutate(uuid)} + busy={busy} + removeLabel={t("remove")} + /> +
    + +
    +

    {t("operatorsTitle")}

    +
    +
    + + setOperatorName(e.target.value)} + /> +
    +
    + + setOperatorUuid(e.target.value)} + /> +
    +
    + + removeOperatorMutation.mutate(uuid)} + busy={busy} + removeLabel={t("remove")} + /> +
    + +
    +

    {t("bansTitle")}

    +
    +
    + + setBanName(e.target.value)} + /> +
    +
    + + setBanReason(e.target.value)} + /> +
    +
    + +
      + {(data.bans ?? []).length === 0 ? ( +
    • {t("bansEmpty")}
    • + ) : ( + (data.bans ?? []).map((ban) => ( +
    • +
      +

      {ban.name}

      + {ban.reason ? ( +

      {ban.reason}

      + ) : null} +
      + +
    • + )) + )} +
    +
    ); } + +function PlayerEntryList({ + entries, + onRemove, + busy, + removeLabel, +}: { + entries: Array<{ uuid: string; name: string }>; + onRemove: (uuid: string) => void; + busy: boolean; + removeLabel: string; +}) { + if (entries.length === 0) { + return null; + } + + return ( +
      + {entries.map((entry) => ( +
    • +
      +

      {entry.name}

      +

      {entry.uuid}

      +
      + +
    • + ))} +
    + ); +} diff --git a/apps/web/src/components/servers/server-schedules.tsx b/apps/web/src/components/servers/server-schedules.tsx new file mode 100644 index 0000000..8b45a0c --- /dev/null +++ b/apps/web/src/components/servers/server-schedules.tsx @@ -0,0 +1,389 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState, Fragment } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { + createSchedule, + deleteSchedule, + listSchedules, + updateSchedule, + type Schedule, + type ScheduleTaskType, +} from "@/lib/api/schedules"; + +interface ServerSchedulesProps { + serverId: string; +} + +const TASK_TYPES: ScheduleTaskType[] = [ + "START", + "STOP", + "RESTART", + "BACKUP", + "CONSOLE_COMMAND", + "UPDATE_CHECK", + "MAINTENANCE", +]; + +const inputClassName = + "flex h-10 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm text-zinc-900 placeholder:text-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-100"; + +const labelClassName = "text-sm font-medium text-zinc-700 dark:text-zinc-300"; + +interface ScheduleFormState { + name: string; + cronExpr: string; + timezone: string; + enabled: boolean; + taskType: ScheduleTaskType; + command: string; +} + +const defaultForm: ScheduleFormState = { + name: "", + cronExpr: "0 4 * * *", + timezone: "UTC", + enabled: true, + taskType: "RESTART", + command: "", +}; + +function scheduleToForm(schedule: Schedule): ScheduleFormState { + const task = schedule.tasks[0]; + const command = + task?.taskType === "CONSOLE_COMMAND" && task.payload?.command + ? String(task.payload.command) + : ""; + return { + name: schedule.name, + cronExpr: schedule.cronExpr, + timezone: schedule.timezone, + enabled: schedule.enabled, + taskType: task?.taskType ?? "RESTART", + command, + }; +} + +function formToPayload(form: ScheduleFormState) { + const payload = + form.taskType === "CONSOLE_COMMAND" && form.command.trim() + ? { command: form.command.trim() } + : undefined; + return { + name: form.name.trim(), + cronExpr: form.cronExpr.trim(), + timezone: form.timezone.trim() || "UTC", + enabled: form.enabled, + tasks: [{ taskType: form.taskType, payload }], + }; +} + +export function ServerSchedules({ serverId }: ServerSchedulesProps) { + const t = useTranslations("servers.schedules"); + const queryClient = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(defaultForm); + const [error, setError] = useState(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["server-schedules", serverId], + queryFn: () => listSchedules(serverId), + }); + + const invalidate = () => + void queryClient.invalidateQueries({ queryKey: ["server-schedules", serverId] }); + + const createMutation = useMutation({ + mutationFn: () => createSchedule(serverId, formToPayload(form)), + onSuccess: () => { + setError(null); + setShowForm(false); + setForm(defaultForm); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed")); + }, + }); + + const updateMutation = useMutation({ + mutationFn: (scheduleId: string) => + updateSchedule(serverId, scheduleId, formToPayload(form)), + onSuccess: () => { + setError(null); + setEditingId(null); + setForm(defaultForm); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed")); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (scheduleId: string) => deleteSchedule(serverId, scheduleId), + onSuccess: () => { + setError(null); + invalidate(); + }, + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.deleteFailed")); + }, + }); + + const toggleMutation = useMutation({ + mutationFn: (schedule: Schedule) => + updateSchedule(serverId, schedule.id, { enabled: !schedule.enabled }), + onSuccess: () => invalidate(), + onError: (err: unknown) => { + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("errors.saveFailed")); + }, + }); + + function startCreate() { + setEditingId(null); + setForm(defaultForm); + setShowForm(true); + setError(null); + } + + function startEdit(schedule: Schedule) { + setShowForm(false); + setEditingId(schedule.id); + setForm(scheduleToForm(schedule)); + setError(null); + } + + function cancelForm() { + setShowForm(false); + setEditingId(null); + setForm(defaultForm); + setError(null); + } + + function handleDelete(schedule: Schedule) { + if (!window.confirm(t("deleteConfirm", { name: schedule.name }))) { + return; + } + deleteMutation.mutate(schedule.id); + } + + const isSaving = createMutation.isPending || updateMutation.isPending; + + function renderForm(onSubmit: () => void, submitLabel: string) { + return ( +
    +
    +
    + + setForm((prev) => ({ ...prev, name: e.target.value }))} + /> +
    +
    + + setForm((prev) => ({ ...prev, cronExpr: e.target.value }))} + /> +
    +
    + + setForm((prev) => ({ ...prev, timezone: e.target.value }))} + /> +
    +
    + + +
    +
    + setForm((prev) => ({ ...prev, enabled: e.target.checked }))} + /> + +
    + {form.taskType === "CONSOLE_COMMAND" ? ( +
    + + setForm((prev) => ({ ...prev, command: e.target.value }))} + placeholder={t("fields.commandPlaceholder")} + /> +
    + ) : null} +
    +
    + + +
    +
    + ); + } + + return ( + + +
    +
    + {t("title")} + {t("description")} +
    + {!showForm && !editingId ? ( + + ) : null} +
    +
    + + {error ? ( +

    + {error} +

    + ) : null} + + {showForm + ? renderForm(() => createMutation.mutate(), t("create")) + : null} + + {isLoading ? ( +

    {t("loading")}

    + ) : isError ? ( +

    {t("errors.loadFailed")}

    + ) : (data?.schedules ?? []).length === 0 && !showForm ? ( +

    {t("empty")}

    + ) : ( +
    + + + + + + + + + + + + {(data?.schedules ?? []).map((schedule) => ( + + + + + + + + + {editingId === schedule.id ? ( + + + + ) : null} + + ))} + +
    + {t("columns.name")} + + {t("columns.cron")} + + {t("columns.task")} + + {t("columns.status")} + + {t("columns.actions")} +
    + {schedule.name} + + {schedule.cronExpr} + + {schedule.tasks.map((task) => t(`taskTypes.${task.taskType}`)).join(", ")} + + + {schedule.enabled ? t("status.enabled") : t("status.disabled")} + + +
    + + + +
    +
    + {renderForm(() => updateMutation.mutate(schedule.id), t("save"))} +
    +
    + )} +
    +
    + ); +} diff --git a/apps/web/src/components/servers/server-software.tsx b/apps/web/src/components/servers/server-software.tsx new file mode 100644 index 0000000..aac5fc4 --- /dev/null +++ b/apps/web/src/components/servers/server-software.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@hexahost/ui"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { ApiError } from "@/lib/api/auth"; +import { getServer } from "@/lib/api/servers"; +import { reinstallServer } from "@/lib/api/software"; + +interface ServerSoftwareProps { + serverId: string; +} + +export function ServerSoftware({ serverId }: ServerSoftwareProps) { + const t = useTranslations("servers.software"); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const { data, isLoading, isError } = useQuery({ + queryKey: ["server", serverId], + queryFn: () => getServer(serverId), + }); + + const reinstallMutation = useMutation({ + mutationFn: () => + reinstallServer(serverId, { + softwareFamily: data!.softwareFamily, + minecraftVersion: data!.minecraftVersion, + createBackup: true, + }), + onSuccess: () => { + setError(null); + setSuccess(true); + void queryClient.invalidateQueries({ queryKey: ["server", serverId] }); + setTimeout(() => setSuccess(false), 4000); + }, + onError: (err: unknown) => { + setSuccess(false); + setError(err instanceof ApiError ? (err.detail ?? err.title) : t("reinstallError")); + }, + }); + + function handleReinstall() { + if (!data || !window.confirm(t("reinstallConfirm"))) { + return; + } + reinstallMutation.mutate(); + } + + if (isLoading) { + return

    {t("loading")}

    ; + } + + if (isError || !data) { + return

    {t("loadError")}

    ; + } + + const canReinstall = data.status === "STOPPED"; + + return ( + + + {t("title")} + {t("description")} + + +
    + {t("family")} + + {data.softwareFamily} + +
    +
    + {t("version")} + + {data.minecraftVersion} + +
    +
    + {t("edition")} + {data.edition} +
    + + {!canReinstall ? ( +

    {t("reinstallStoppedHint")}

    + ) : null} + + {error ? ( +

    + {error} +

    + ) : null} + + {success ? ( +

    {t("reinstallStarted")}

    + ) : null} + + +
    +
    + ); +} diff --git a/apps/web/src/components/servers/server-tabs.tsx b/apps/web/src/components/servers/server-tabs.tsx index 272e6d9..d1c5adb 100644 --- a/apps/web/src/components/servers/server-tabs.tsx +++ b/apps/web/src/components/servers/server-tabs.tsx @@ -12,10 +12,13 @@ const tabs = [ { key: "console", suffix: "/console" }, { key: "files", suffix: "/files" }, { key: "properties", suffix: "/properties" }, + { key: "software", suffix: "/software" }, { key: "players", suffix: "/players" }, { key: "worlds", suffix: "/worlds" }, { key: "backups", suffix: "/backups" }, { key: "addons", suffix: "/addons" }, + { key: "schedules", suffix: "/schedules" }, + { key: "access", suffix: "/access" }, ] as const; export function ServerTabs({ serverId }: ServerTabsProps) { diff --git a/apps/web/src/lib/api/admin.ts b/apps/web/src/lib/api/admin.ts new file mode 100644 index 0000000..51c695b --- /dev/null +++ b/apps/web/src/lib/api/admin.ts @@ -0,0 +1,143 @@ +import { apiFetch } from "./auth"; + +export interface AdminDashboardStats { + activeUsers: number; + totalServers: number; + runningServers: number; + queueLength: number; + onlineNodes: number; + failedJobs24h: number; + openAbuseReports: number; + openTickets: number; + generatedAt: string; +} + +export interface AdminUser { + id: string; + email: string; + username: string; + displayName: string | null; + status: string; + roles: string[]; + twoFactorEnabled: boolean; + sessionCount: number; + serverCount: number; + createdAt: string; +} + +export interface AdminJob { + id: string; + queue: string; + jobName: string; + status: string; + attempts: number; + error: string | null; + startedAt: string | null; + completedAt: string | null; + createdAt: string; +} + +export interface AdminAuditEvent { + id: string; + action: string; + entityType: string | null; + entityId: string | null; + metadata: unknown; + ipAddress: string | null; + createdAt: string; + user: { id: string; email: string; username: string } | null; +} + +export interface GameNodeSummary { + id: string; + name: string; + hostname: string; + status: string; + maxServers: number; + maxRamMb: number; + platformRamMb: number; + activeServers: number; + reservedRamMb: number; + availableRamMb: number; + lastHeartbeatAt: string | null; + agentVersion: string | null; + drainRequestedAt: string | null; + createdAt: string; +} + +export async function getAdminDashboardStats(): Promise { + return apiFetch("/admin/dashboard"); +} + +export async function listAdminNodes(): Promise<{ nodes: GameNodeSummary[] }> { + return apiFetch<{ nodes: GameNodeSummary[] }>("/admin/nodes"); +} + +export async function drainAdminNode(nodeId: string): Promise { + return apiFetch(`/admin/nodes/${nodeId}/drain`, { + method: "POST", + }); +} + +export async function setAdminNodeMaintenance( + nodeId: string, + enabled: boolean, +): Promise { + return apiFetch(`/admin/nodes/${nodeId}/maintenance`, { + method: "POST", + body: JSON.stringify({ enabled }), + }); +} + +export async function searchAdminUsers(params?: { + q?: string; + limit?: number; +}): Promise<{ users: AdminUser[] }> { + const search = new URLSearchParams(); + if (params?.q) search.set("q", params.q); + if (params?.limit) search.set("limit", String(params.limit)); + const query = search.toString(); + return apiFetch<{ users: AdminUser[] }>( + `/admin/users${query ? `?${query}` : ""}`, + ); +} + +export async function suspendAdminUser(userId: string): Promise<{ id: string; status: string }> { + return apiFetch<{ id: string; status: string }>(`/admin/users/${userId}/suspend`, { + method: "POST", + }); +} + +export async function unsuspendAdminUser(userId: string): Promise<{ id: string; status: string }> { + return apiFetch<{ id: string; status: string }>(`/admin/users/${userId}/unsuspend`, { + method: "POST", + }); +} + +export async function listAdminJobs(params?: { + limit?: number; + status?: string; + queue?: string; +}): Promise<{ jobs: AdminJob[] }> { + const search = new URLSearchParams(); + if (params?.limit) search.set("limit", String(params.limit)); + if (params?.status) search.set("status", params.status); + if (params?.queue) search.set("queue", params.queue); + const query = search.toString(); + return apiFetch<{ jobs: AdminJob[] }>(`/admin/jobs${query ? `?${query}` : ""}`); +} + +export async function listAdminAuditEvents(params?: { + limit?: number; + cursor?: string; + action?: string; +}): Promise<{ items: AdminAuditEvent[]; nextCursor: string | null }> { + const search = new URLSearchParams(); + if (params?.limit) search.set("limit", String(params.limit)); + if (params?.cursor) search.set("cursor", params.cursor); + if (params?.action) search.set("action", params.action); + const query = search.toString(); + return apiFetch<{ items: AdminAuditEvent[]; nextCursor: string | null }>( + `/admin/audit${query ? `?${query}` : ""}`, + ); +} diff --git a/apps/web/src/lib/api/auth.ts b/apps/web/src/lib/api/auth.ts index 1344c20..6294db4 100644 --- a/apps/web/src/lib/api/auth.ts +++ b/apps/web/src/lib/api/auth.ts @@ -102,6 +102,40 @@ async function parseProblemResponse(response: Response): Promise { }); } +let csrfToken: string | null = null; + +const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +async function fetchCsrfToken(): Promise { + const endpoint = `${getApiUrl()}/auth/csrf`; + + const response = await fetch(endpoint, { + credentials: "include", + }); + + if (!response.ok) { + throw new Error("Failed to fetch CSRF token"); + } + + const data = (await response.json()) as { token: string }; + csrfToken = data.token; + return data.token; +} + +async function ensureCsrfToken(method: string): Promise { + if (!MUTATION_METHODS.has(method.toUpperCase())) { + return; + } + + if (!csrfToken) { + await fetchCsrfToken(); + } +} + +export function clearCsrfToken(): void { + csrfToken = null; +} + export async function apiFetch( path: string, options: RequestInit = {}, @@ -112,12 +146,19 @@ export async function apiFetch( throw new Error("Invalid API URL configuration"); } + const method = (options.method ?? "GET").toUpperCase(); + await ensureCsrfToken(method); + const headers = new Headers(options.headers); if (options.body && !headers.has("Content-Type")) { headers.set("Content-Type", "application/json"); } + if (MUTATION_METHODS.has(method) && csrfToken) { + headers.set("x-csrf-token", csrfToken); + } + const response = await fetch(endpoint, { ...options, credentials: "include", diff --git a/apps/web/src/lib/api/compliance.ts b/apps/web/src/lib/api/compliance.ts new file mode 100644 index 0000000..139c27c --- /dev/null +++ b/apps/web/src/lib/api/compliance.ts @@ -0,0 +1,47 @@ +import { apiFetch } from "./auth"; + +export interface DataExportRequest { + id: string; + status: "PENDING" | "PROCESSING" | "READY" | "FAILED" | "EXPIRED"; + downloadUrl: string | null; + expiresAt: string | null; + completedAt: string | null; + createdAt: string; +} + +export interface AccountDeletionRequest { + id: string; + status: "PENDING" | "SCHEDULED" | "COMPLETED" | "CANCELLED"; + scheduledFor: string; + completedAt: string | null; + createdAt: string; +} + +export async function requestDataExport(): Promise { + return apiFetch("/account/compliance/export", { + method: "POST", + }); +} + +export async function listDataExports(): Promise<{ exports: DataExportRequest[] }> { + return apiFetch("/account/compliance/export"); +} + +export async function requestAccountDeletion( + confirmEmail: string, +): Promise { + return apiFetch("/account/compliance/deletion", { + method: "POST", + body: JSON.stringify({ confirmEmail }), + }); +} + +export async function getAccountDeletionStatus(): Promise { + return apiFetch("/account/compliance/deletion"); +} + +export async function cancelAccountDeletion(): Promise { + return apiFetch("/account/compliance/deletion", { + method: "DELETE", + }); +} diff --git a/apps/web/src/lib/api/files.ts b/apps/web/src/lib/api/files.ts index e05b0f0..b131818 100644 --- a/apps/web/src/lib/api/files.ts +++ b/apps/web/src/lib/api/files.ts @@ -35,9 +35,28 @@ export async function listFiles( params.set("path", path); } const query = params.toString(); - return apiFetch( - `/servers/${serverId}/files${query ? `?${query}` : ""}`, - ); + const response = await apiFetch<{ + path: string; + entries: Array<{ + name: string; + path: string; + type?: "file" | "directory"; + isDir?: boolean; + size?: number; + modifiedAt?: string; + }>; + }>(`/servers/${serverId}/files${query ? `?${query}` : ""}`); + + return { + path: response.path, + entries: response.entries.map((entry) => ({ + name: entry.name, + path: entry.path, + isDir: entry.isDir ?? entry.type === "directory", + size: entry.size ?? 0, + modifiedAt: entry.modifiedAt, + })), + }; } export async function readFile( @@ -59,3 +78,62 @@ export async function writeFile( body: JSON.stringify(payload), }); } + +export interface UploadFilePayload { + path: string; + content: string; + encoding?: "utf-8" | "base64"; +} + +export interface ArchiveFilesPayload { + paths: string[]; + archiveName?: string; +} + +export interface UnarchiveFilePayload { + archivePath: string; + destinationPath?: string; +} + +export async function deleteFile( + serverId: string, + path: string, +): Promise { + const params = new URLSearchParams({ path }); + await apiFetch(`/servers/${serverId}/files?${params.toString()}`, { + method: "DELETE", + }); +} + +export async function uploadFile( + serverId: string, + payload: UploadFilePayload, +): Promise<{ path: string }> { + return apiFetch(`/servers/${serverId}/files/upload`, { + method: "POST", + body: JSON.stringify({ + encoding: "base64", + ...payload, + }), + }); +} + +export async function archiveFiles( + serverId: string, + payload: ArchiveFilesPayload, +): Promise<{ archivePath: string }> { + return apiFetch(`/servers/${serverId}/files/archive`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function unarchiveFile( + serverId: string, + payload: UnarchiveFilePayload, +): Promise<{ jobId?: string }> { + return apiFetch(`/servers/${serverId}/files/unarchive`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/apps/web/src/lib/api/members.ts b/apps/web/src/lib/api/members.ts new file mode 100644 index 0000000..61dd0a1 --- /dev/null +++ b/apps/web/src/lib/api/members.ts @@ -0,0 +1,52 @@ +import { apiFetch } from "./auth"; + +export type MemberRole = "ADMIN" | "OPERATOR" | "DEVELOPER" | "VIEWER"; + +export type ServerMemberRole = MemberRole | "OWNER"; + +export interface ServerMember { + id: string; + userId: string; + role: ServerMemberRole; + email: string; + username: string; + displayName: string | null; + acceptedAt: string | null; +} + +export interface MemberListResponse { + members: ServerMember[]; +} + +export interface InviteMemberPayload { + email: string; + role: MemberRole; +} + +export interface InviteMemberResponse { + status?: string; + message?: string; + id?: string; + userId?: string; + role?: string; +} + +export async function listMembers(serverId: string): Promise { + return apiFetch(`/servers/${serverId}/members`); +} + +export async function inviteMember( + serverId: string, + payload: InviteMemberPayload, +): Promise { + return apiFetch(`/servers/${serverId}/members`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeMember(serverId: string, memberId: string): Promise { + await apiFetch(`/servers/${serverId}/members/${memberId}`, { + method: "DELETE", + }); +} diff --git a/apps/web/src/lib/api/notifications.ts b/apps/web/src/lib/api/notifications.ts new file mode 100644 index 0000000..e0e0284 --- /dev/null +++ b/apps/web/src/lib/api/notifications.ts @@ -0,0 +1,75 @@ +import { apiFetch } from "./auth"; + +export type NotificationType = + | "SERVER_READY" + | "SERVER_START_FAILED" + | "SERVER_STOPPED" + | "IDLE_STOP_WARNING" + | "BACKUP_SUCCESS" + | "BACKUP_FAILED" + | "RESTORE_SUCCESS" + | "RESTORE_FAILED" + | "CREDITS_LOW" + | "BILLING_ISSUE" + | "INVITE" + | "NODE_INCIDENT" + | "MAINTENANCE" + | "SECURITY" + | "NEW_LOGIN" + | "TWO_FA_CHANGED"; + +export type NotificationChannel = "IN_APP" | "EMAIL" | "WEBHOOK" | "DISCORD"; + +export interface Notification { + id: string; + type: NotificationType; + channel: NotificationChannel; + title: string; + body: string; + readAt: string | null; + createdAt: string; +} + +export interface NotificationListResponse { + notifications: Notification[]; + unreadCount: number; +} + +export interface NotificationPreference { + type: NotificationType; + channel: NotificationChannel; + enabled: boolean; +} + +export interface NotificationPreferencesResponse { + preferences: NotificationPreference[]; +} + +export async function listNotifications(): Promise { + return apiFetch("/account/notifications"); +} + +export async function markNotificationRead(id: string): Promise<{ read: boolean }> { + return apiFetch(`/account/notifications/${id}/read`, { + method: "POST", + }); +} + +export async function markAllNotificationsRead(): Promise<{ read: boolean }> { + return apiFetch("/account/notifications/read-all", { + method: "POST", + }); +} + +export async function getNotificationPreferences(): Promise { + return apiFetch("/account/notifications/preferences"); +} + +export async function updateNotificationPreferences( + preferences: NotificationPreference[], +): Promise { + return apiFetch("/account/notifications/preferences", { + method: "PATCH", + body: JSON.stringify({ preferences }), + }); +} diff --git a/apps/web/src/lib/api/players.ts b/apps/web/src/lib/api/players.ts index b0be45f..78abe52 100644 --- a/apps/web/src/lib/api/players.ts +++ b/apps/web/src/lib/api/players.ts @@ -6,12 +6,124 @@ export interface OnlinePlayer { ping?: number; } +export interface WhitelistEntry { + uuid: string; + name: string; +} + +export interface OperatorEntry { + uuid: string; + name: string; + level?: number; + bypassesPlayerLimit?: boolean; +} + +export interface BanEntry { + uuid?: string; + name: string; + reason?: string; + source?: string; + expires?: string; +} + export interface PlayerListResponse { online: OnlinePlayer[]; - maxPlayers: number; - whitelistCount: number; + whitelist: WhitelistEntry[]; + operators: OperatorEntry[]; + bans?: BanEntry[]; +} + +export interface AddWhitelistPayload { + uuid: string; + name: string; +} + +export interface AddOperatorPayload { + uuid: string; + name: string; + level?: number; + bypassesPlayerLimit?: boolean; +} + +export interface RemovePlayerPayload { + uuid: string; +} + +export interface AddBanPayload { + name: string; + reason?: string; } export async function getPlayers(serverId: string): Promise { return apiFetch(`/servers/${serverId}/players`); } + +export async function addWhitelist( + serverId: string, + payload: AddWhitelistPayload, +): Promise<{ whitelist: WhitelistEntry[] }> { + return apiFetch(`/servers/${serverId}/players/whitelist`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeWhitelist( + serverId: string, + payload: RemovePlayerPayload, +): Promise<{ whitelist: WhitelistEntry[] }> { + return apiFetch(`/servers/${serverId}/players/whitelist`, { + method: "DELETE", + body: JSON.stringify(payload), + }); +} + +export async function addOperator( + serverId: string, + payload: AddOperatorPayload, +): Promise<{ operators: OperatorEntry[] }> { + return apiFetch(`/servers/${serverId}/players/operators`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeOperator( + serverId: string, + payload: RemovePlayerPayload, +): Promise<{ operators: OperatorEntry[] }> { + return apiFetch(`/servers/${serverId}/players/operators`, { + method: "DELETE", + body: JSON.stringify(payload), + }); +} + +export async function addBan( + serverId: string, + payload: AddBanPayload, +): Promise<{ bans: BanEntry[] }> { + return apiFetch(`/servers/${serverId}/players/bans`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function removeBan( + serverId: string, + payload: { name: string }, +): Promise<{ bans: BanEntry[] }> { + return apiFetch(`/servers/${serverId}/players/bans`, { + method: "DELETE", + body: JSON.stringify(payload), + }); +} + +export async function kickPlayer( + serverId: string, + payload: AddBanPayload, +): Promise<{ kicked: boolean }> { + return apiFetch(`/servers/${serverId}/players/kick`, { + method: "POST", + body: JSON.stringify(payload), + }); +} diff --git a/apps/web/src/lib/api/schedules.ts b/apps/web/src/lib/api/schedules.ts new file mode 100644 index 0000000..a735abb --- /dev/null +++ b/apps/web/src/lib/api/schedules.ts @@ -0,0 +1,78 @@ +import { apiFetch } from "./auth"; + +export type ScheduleTaskType = + | "START" + | "STOP" + | "RESTART" + | "BACKUP" + | "CONSOLE_COMMAND" + | "UPDATE_CHECK" + | "MAINTENANCE"; + +export interface ScheduledTask { + id: string; + taskType: ScheduleTaskType; + payload: Record | null; +} + +export interface Schedule { + id: string; + name: string; + timezone: string; + cronExpr: string; + enabled: boolean; + nextRunAt: string | null; + lastRunAt: string | null; + tasks: ScheduledTask[]; +} + +export interface ScheduleListResponse { + schedules: Schedule[]; +} + +export interface CreateSchedulePayload { + name: string; + timezone?: string; + cronExpr: string; + enabled?: boolean; + tasks: Array<{ + taskType: ScheduleTaskType; + payload?: Record; + }>; +} + +export type UpdateSchedulePayload = Partial; + +export async function listSchedules(serverId: string): Promise { + return apiFetch(`/servers/${serverId}/schedules`); +} + +export async function createSchedule( + serverId: string, + payload: CreateSchedulePayload, +): Promise { + return apiFetch(`/servers/${serverId}/schedules`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateSchedule( + serverId: string, + scheduleId: string, + payload: UpdateSchedulePayload, +): Promise { + return apiFetch(`/servers/${serverId}/schedules/${scheduleId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function deleteSchedule( + serverId: string, + scheduleId: string, +): Promise { + await apiFetch(`/servers/${serverId}/schedules/${scheduleId}`, { + method: "DELETE", + }); +} diff --git a/apps/web/src/lib/api/software.ts b/apps/web/src/lib/api/software.ts new file mode 100644 index 0000000..2803b94 --- /dev/null +++ b/apps/web/src/lib/api/software.ts @@ -0,0 +1,27 @@ +import { apiFetch } from "./auth"; +import type { SoftwareFamily } from "./servers"; + +export interface ReinstallServerPayload { + softwareFamily: SoftwareFamily; + minecraftVersion: string; + createBackup?: boolean; +} + +export interface ReinstallServerResponse { + serverId: string; + jobId: string; + status: string; +} + +export async function reinstallServer( + serverId: string, + payload: ReinstallServerPayload, +): Promise { + return apiFetch(`/servers/${serverId}/software/reinstall`, { + method: "POST", + body: JSON.stringify({ + createBackup: true, + ...payload, + }), + }); +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 5609f21..8a7d796 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -4,7 +4,15 @@ import { routing } from "./i18n/routing"; const intlMiddleware = createMiddleware(routing); -const protectedPaths = ["/dashboard", "/security", "/servers"]; +const protectedPaths = [ + "/dashboard", + "/security", + "/servers", + "/billing", + "/profile", + "/notifications", + "/admin", +]; function getPathWithoutLocale(pathname: string): string { if (pathname === "/en" || pathname.startsWith("/en/")) { @@ -22,7 +30,31 @@ function buildLocalizedPath(path: string, pathname: string): string { return path; } -export default function middleware(request: NextRequest) { +function getApiUrl(): string { + return process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001/api/v1"; +} + +async function fetchCurrentUserRoles( + sessionValue: string, +): Promise { + try { + const response = await fetch(`${getApiUrl()}/me`, { + headers: { Cookie: `hgc_session=${sessionValue}` }, + cache: "no-store", + }); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { user?: { roles?: string[] } }; + return data.user?.roles ?? null; + } catch { + return null; + } +} + +export default async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const pathWithoutLocale = getPathWithoutLocale(pathname); @@ -41,6 +73,20 @@ export default function middleware(request: NextRequest) { loginUrl.searchParams.set("redirect", pathname); return NextResponse.redirect(loginUrl); } + + const isAdminRoute = + pathWithoutLocale === "/admin" || + pathWithoutLocale.startsWith("/admin/"); + + if (isAdminRoute) { + const roles = await fetchCurrentUserRoles(session.value); + + if (!roles?.includes("SUPER_ADMIN")) { + const dashboardUrl = request.nextUrl.clone(); + dashboardUrl.pathname = buildLocalizedPath("/dashboard", pathname); + return NextResponse.redirect(dashboardUrl); + } + } } return intlMiddleware(request); diff --git a/apps/worker/package.json b/apps/worker/package.json index f1234c4..566f38b 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -16,9 +16,13 @@ "@hexahost/config": "workspace:*", "@hexahost/database": "workspace:*", "@hexahost/dns": "workspace:*", + "@hexahost/email-templates": "workspace:*", "@hexahost/metering": "workspace:*", "@hexahost/scheduler": "workspace:*", + "@hexahost/server-state": "workspace:*", "@hexahost/storage": "workspace:*", + "@aws-sdk/client-s3": "^3.758.0", + "cron-parser": "^5.0.4", "bullmq": "^5.34.10", "ioredis": "^5.4.2", "nodemailer": "^6.10.0", diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index 86f8239..e4a7f3e 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -1,7 +1,13 @@ import { z } from 'zod'; export const notificationEmailJobSchema = z.object({ - type: z.enum(['email_verification', 'password_reset']), + type: z.enum([ + 'email_verification', + 'password_reset', + 'server_ready', + 'backup_success', + 'backup_failed', + ]), to: z.string().email(), subject: z.string(), template: z.string(), diff --git a/apps/worker/src/handlers/notification-dispatch.ts b/apps/worker/src/handlers/notification-dispatch.ts new file mode 100644 index 0000000..234dcf6 --- /dev/null +++ b/apps/worker/src/handlers/notification-dispatch.ts @@ -0,0 +1,106 @@ +import { Job } from 'bullmq'; +import { z } from 'zod'; + +import { renderBackupFailedEmail, renderBackupSuccessEmail, renderServerReadyEmail } from '@hexahost/email-templates'; + +import { getSmtpConfig } from '../config'; +import { sendEmail } from '../email'; +import { logger } from '../logger'; + +const dispatchJobSchema = z.object({ + userId: z.string().uuid(), + type: z.string(), + channel: z.enum(['EMAIL', 'WEBHOOK', 'DISCORD']), + title: z.string(), + body: z.string(), + metadata: z.record(z.unknown()).optional(), +}); + +async function postWebhook(url: string, payload: Record): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`Webhook delivery failed: ${response.status}`); + } +} + +async function postDiscord(webhookUrl: string, content: string): Promise { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: content.slice(0, 1900) }), + }); + if (!response.ok) { + throw new Error(`Discord delivery failed: ${response.status}`); + } +} + +export async function processNotificationDispatchJob(job: Job): Promise<{ delivered: boolean }> { + if (job.name !== 'dispatch-notification') { + return { delivered: false }; + } + + const payload = dispatchJobSchema.parse(job.data); + const config = getSmtpConfig(); + const locale = payload.metadata?.['locale'] === 'de' ? 'de' : 'en'; + const serverName = String(payload.metadata?.['serverName'] ?? 'Server'); + + if (payload.channel === 'EMAIL') { + const userEmail = payload.metadata?.['email']; + if (typeof userEmail !== 'string') { + logger.warn({ jobId: job.id }, 'Email notification missing recipient'); + return { delivered: false }; + } + + let subject = payload.title; + let html = `

    ${payload.body}

    `; + + if (payload.type === 'SERVER_READY') { + const rendered = renderServerReadyEmail({ appName: config.APP_NAME, serverName, locale }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'BACKUP_SUCCESS') { + const rendered = renderBackupSuccessEmail({ appName: config.APP_NAME, serverName, locale }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'BACKUP_FAILED') { + const rendered = renderBackupFailedEmail({ appName: config.APP_NAME, serverName, locale }); + subject = rendered.subject; + html = rendered.html; + } + + await sendEmail({ to: userEmail, subject, html }); + return { delivered: true }; + } + + if (payload.channel === 'WEBHOOK') { + const url = process.env['NOTIFICATION_WEBHOOK_URL']; + if (!url) { + logger.warn('NOTIFICATION_WEBHOOK_URL not configured'); + return { delivered: false }; + } + await postWebhook(url, { + userId: payload.userId, + type: payload.type, + title: payload.title, + body: payload.body, + metadata: payload.metadata ?? {}, + }); + return { delivered: true }; + } + + if (payload.channel === 'DISCORD') { + const url = process.env['NOTIFICATION_DISCORD_WEBHOOK_URL']; + if (!url) { + logger.warn('NOTIFICATION_DISCORD_WEBHOOK_URL not configured'); + return { delivered: false }; + } + await postDiscord(url, `**${payload.title}**\n${payload.body}`); + return { delivered: true }; + } + + return { delivered: false }; +} diff --git a/apps/worker/src/handlers/notifications.ts b/apps/worker/src/handlers/notifications.ts index d636fe3..249bea0 100644 --- a/apps/worker/src/handlers/notifications.ts +++ b/apps/worker/src/handlers/notifications.ts @@ -1,9 +1,22 @@ import { Job } from 'bullmq'; +import { + renderBackupFailedEmail, + renderBackupSuccessEmail, + renderResetEmail, + renderServerReadyEmail, + renderVerifyEmail, + type EmailLocale, +} from '@hexahost/email-templates'; + import { getSmtpConfig, notificationEmailJobSchema } from '../config'; import { sendEmail } from '../email'; import { logger } from '../logger'; +function localeFromData(data: Record): EmailLocale { + return data['locale'] === 'de' ? 'de' : 'en'; +} + export async function processNotificationJob(job: Job): Promise<{ sent: boolean }> { if (job.name !== 'send-email') { logger.warn({ jobName: job.name }, 'Unknown notification job'); @@ -12,19 +25,58 @@ export async function processNotificationJob(job: Job): Promise<{ sent: boolean const payload = notificationEmailJobSchema.parse(job.data); const config = getSmtpConfig(); + const locale = localeFromData(payload.data); + let subject = payload.subject; let html: string; + if (payload.type === 'email_verification') { - const verifyUrl = String(payload.data['verifyUrl'] ?? ''); - html = `

    Welcome to ${config.APP_NAME}!

    Verify your email address

    This link expires in 24 hours.

    `; + const rendered = renderVerifyEmail({ + appName: config.APP_NAME, + url: String(payload.data['verifyUrl'] ?? ''), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'password_reset') { + const rendered = renderResetEmail({ + appName: config.APP_NAME, + url: String(payload.data['resetUrl'] ?? ''), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'server_ready') { + const rendered = renderServerReadyEmail({ + appName: config.APP_NAME, + serverName: String(payload.data['serverName'] ?? 'Server'), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'backup_success') { + const rendered = renderBackupSuccessEmail({ + appName: config.APP_NAME, + serverName: String(payload.data['serverName'] ?? 'Server'), + locale, + }); + subject = rendered.subject; + html = rendered.html; + } else if (payload.type === 'backup_failed') { + const rendered = renderBackupFailedEmail({ + appName: config.APP_NAME, + serverName: String(payload.data['serverName'] ?? 'Server'), + locale, + }); + subject = rendered.subject; + html = rendered.html; } else { - const resetUrl = String(payload.data['resetUrl'] ?? ''); - html = `

    You requested a password reset for ${config.APP_NAME}.

    Set a new password

    If you did not request this, ignore this email.

    `; + html = `

    ${payload.subject}

    `; } await sendEmail({ to: payload.to, - subject: payload.subject, + subject, html, }); diff --git a/apps/worker/src/handlers/schedules-compliance.ts b/apps/worker/src/handlers/schedules-compliance.ts new file mode 100644 index 0000000..cc741f8 --- /dev/null +++ b/apps/worker/src/handlers/schedules-compliance.ts @@ -0,0 +1,392 @@ +import { randomUUID } from 'node:crypto'; + +import { PutObjectCommand } from '@aws-sdk/client-s3'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { CronExpressionParser } from 'cron-parser'; + +import { searchModrinthProjects } from '@hexahost/catalog'; +import { getConfig } from '@hexahost/config'; +import { prisma } from '@hexahost/database'; +import type { GameServerStatus } from '@hexahost/server-state'; +import { ALLOWED_TRANSITIONS } from '@hexahost/server-state'; +import { + createS3Client, + loadStorageConfig, +} from '@hexahost/storage'; + +import { logger } from '../logger'; +import { getBackupsQueue, getLifecycleQueue } from '../queues'; + +export async function processScheduleTick(): Promise { + const now = new Date(); + const due = await prisma.schedule.findMany({ + where: { + enabled: true, + nextRunAt: { lte: now }, + }, + include: { tasks: true, server: true }, + take: 50, + }); + + if (due.length === 0) { + return; + } + + const lifecycleQueue = getLifecycleQueue(); + + for (const schedule of due) { + for (const task of schedule.tasks) { + const correlationId = `${schedule.id}:${task.id}:${now.getTime()}`; + if (task.taskType === 'START') { + await lifecycleQueue.add('start-server', { + serverId: schedule.serverId, + action: 'start', + correlationId, + }); + } else if (task.taskType === 'STOP') { + await lifecycleQueue.add('stop-server', { + serverId: schedule.serverId, + action: 'stop', + correlationId, + }); + } else if (task.taskType === 'RESTART') { + await lifecycleQueue.add('restart-server', { + serverId: schedule.serverId, + action: 'restart', + correlationId, + }); + } else if (task.taskType === 'BACKUP') { + const backupsQueue = getBackupsQueue(); + await backupsQueue.add('create-backup', { + serverId: schedule.serverId, + type: 'SCHEDULED', + }); + } else if (task.taskType === 'CONSOLE_COMMAND') { + const command = + typeof task.payload === 'object' && + task.payload !== null && + 'command' in task.payload && + typeof (task.payload as { command: unknown }).command === 'string' + ? (task.payload as { command: string }).command + : null; + if (command) { + await lifecycleQueue.add('console-command', { + serverId: schedule.serverId, + command, + correlationId, + }); + } + } + } + + const nextRunAt = computeNextScheduleRun(schedule.cronExpr, schedule.timezone); + await prisma.schedule.update({ + where: { id: schedule.id }, + data: { lastRunAt: now, nextRunAt }, + }); + } + + logger.info({ count: due.length }, 'Processed due schedules'); +} + +function computeNextScheduleRun(cronExpr: string, timezone: string): Date { + try { + return CronExpressionParser.parse(cronExpr, { tz: timezone, currentDate: new Date() }) + .next() + .toDate(); + } catch { + const fallback = new Date(); + fallback.setMinutes(fallback.getMinutes() + 5); + return fallback; + } +} + +export async function processCatalogSyncTick(): Promise { + const versions = ['1.21.1', '1.21', '1.20.6', '1.20.4']; + for (const version of versions) { + await prisma.minecraftVersion.upsert({ + where: { version }, + create: { version, edition: 'JAVA', isActive: true }, + update: { isActive: true }, + }); + } + + await prisma.catalogProvider.upsert({ + where: { kind: 'MODRINTH' }, + create: { kind: 'MODRINTH', name: 'Modrinth', isEnabled: true }, + update: { isEnabled: true, lastSyncAt: new Date() }, + }); + + try { + const result = await searchModrinthProjects({ + query: 'fabric api', + projectType: 'mod', + gameVersion: '1.21.1', + loader: 'fabric', + limit: 5, + }); + const provider = await prisma.catalogProvider.findUnique({ where: { kind: 'MODRINTH' } }); + if (provider) { + for (const hit of result.hits.slice(0, 5)) { + await prisma.catalogProject.upsert({ + where: { + providerId_externalId: { + providerId: provider.id, + externalId: hit.project_id, + }, + }, + create: { + providerId: provider.id, + externalId: hit.project_id, + slug: hit.slug, + name: hit.title, + projectType: hit.project_type, + metadata: { downloads: hit.downloads }, + }, + update: { + slug: hit.slug, + name: hit.title, + syncedAt: new Date(), + }, + }); + } + } + } catch (error) { + logger.warn({ error }, 'Modrinth catalog sync skipped'); + } + + logger.info({ versions: versions.length }, 'Catalog sync tick completed'); +} + +const EXPORT_TTL_DAYS = 7; +const EXPORT_BLOB_PREFIX = 'export:blob:'; + +function isStorageConfigured(): boolean { + return Boolean(process.env['S3_ENDPOINT']?.trim()); +} + +function exportObjectKey(userId: string, exportId: string): string { + return `users/${userId}/exports/${exportId}.json`; +} + +export async function processComplianceTick(): Promise { + const config = getConfig(); + const redis = new Redis(config.REDIS_URL, { maxRetriesPerRequest: null }); + const lifecycleQueue = new Queue('server-lifecycle', { + connection: { url: config.REDIS_URL, maxRetriesPerRequest: null }, + }); + + try { + const exports = await prisma.dataExportRequest.findMany({ + where: { status: 'PENDING' }, + take: 10, + }); + + for (const row of exports) { + await processDataExport(row.id, redis); + } + + const deletions = await prisma.accountDeletionRequest.findMany({ + where: { status: 'SCHEDULED', scheduledFor: { lte: new Date() } }, + take: 10, + }); + + for (const row of deletions) { + await executeAccountDeletion(row.id, lifecycleQueue); + } + + logger.info( + { exports: exports.length, deletions: deletions.length }, + 'Compliance tick completed', + ); + } finally { + await lifecycleQueue.close(); + redis.disconnect(); + } +} + +async function processDataExport(exportId: string, redis: Redis): Promise { + const row = await prisma.dataExportRequest.findUnique({ where: { id: exportId } }); + if (!row || row.status !== 'PENDING') { + return; + } + + await prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'PROCESSING' }, + }); + + try { + const payload = await collectUserExportData(row.userId); + const json = JSON.stringify(payload, null, 2); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + EXPORT_TTL_DAYS); + + let downloadUrl: string; + + if (isStorageConfigured()) { + const storageConfig = loadStorageConfig(); + const client = createS3Client(storageConfig); + const key = exportObjectKey(row.userId, exportId); + await client.send( + new PutObjectCommand({ + Bucket: storageConfig.S3_BUCKET, + Key: key, + Body: json, + ContentType: 'application/json', + }), + ); + downloadUrl = `s3://${storageConfig.S3_BUCKET}/${key}`; + } else { + await redis.set( + `${EXPORT_BLOB_PREFIX}${exportId}`, + json, + 'EX', + EXPORT_TTL_DAYS * 24 * 60 * 60, + ); + const appConfig = getConfig(); + downloadUrl = `${appConfig.API_URL}/account/compliance/export/${exportId}/download`; + } + + await prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { + status: 'READY', + downloadUrl, + expiresAt, + completedAt: new Date(), + }, + }); + } catch (error) { + logger.error({ exportId, error }, 'Data export failed'); + await prisma.dataExportRequest.update({ + where: { id: exportId }, + data: { status: 'FAILED' }, + }); + } +} + +async function executeAccountDeletion(requestId: string, lifecycleQueue: Queue): Promise { + const request = await prisma.accountDeletionRequest.findUnique({ + where: { id: requestId }, + }); + if (!request || request.status !== 'SCHEDULED') { + return; + } + + const userId = request.userId; + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user || user.status === 'DELETED') { + await prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }); + return; + } + + const servers = await prisma.gameServer.findMany({ + where: { userId, status: { not: 'DELETED' } }, + }); + + for (const server of servers) { + if (server.status === 'RUNNING' || server.status === 'STARTING') { + continue; + } + + if (ALLOWED_TRANSITIONS[server.status as GameServerStatus]?.includes('DELETING')) { + const correlationId = randomUUID(); + await prisma.gameServer.update({ + where: { id: server.id }, + data: { status: 'DELETING' }, + }); + await lifecycleQueue.add( + 'delete-server', + { serverId: server.id, action: 'delete', correlationId }, + { jobId: correlationId }, + ); + } + } + + const anonymizedId = randomUUID().slice(0, 8); + await prisma.$transaction([ + prisma.userSession.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + prisma.user.update({ + where: { id: userId }, + data: { + email: `deleted-${anonymizedId}@deleted.invalid`, + username: `deleted-${anonymizedId}`, + displayName: null, + status: 'DELETED', + }, + }), + prisma.accountDeletionRequest.update({ + where: { id: requestId }, + data: { status: 'COMPLETED', completedAt: new Date() }, + }), + ]); +} + +async function collectUserExportData(userId: string) { + const [user, servers, auditEvents] = await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + displayName: true, + status: true, + emailVerifiedAt: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.gameServer.findMany({ + where: { userId }, + select: { + id: true, + name: true, + status: true, + edition: true, + softwareFamily: true, + minecraftVersion: true, + ramMb: true, + hostPort: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: 'desc' }, + }), + prisma.auditEvent.findMany({ + where: { userId }, + select: { + action: true, + entityType: true, + entityId: true, + createdAt: true, + }, + orderBy: { createdAt: 'desc' }, + take: 500, + }), + ]); + + const actionCounts = auditEvents.reduce>((acc, event) => { + acc[event.action] = (acc[event.action] ?? 0) + 1; + return acc; + }, {}); + + return { + exportedAt: new Date().toISOString(), + profile: user, + servers, + auditSummary: { + totalEvents: auditEvents.length, + actionCounts, + recentEvents: auditEvents.slice(0, 50), + }, + }; +} diff --git a/apps/worker/src/handlers/server-lifecycle.ts b/apps/worker/src/handlers/server-lifecycle.ts index 8ee788a..34c7b43 100644 --- a/apps/worker/src/handlers/server-lifecycle.ts +++ b/apps/worker/src/handlers/server-lifecycle.ts @@ -170,8 +170,31 @@ export async function processLifecycleJob(job: Job): Promise<{ status: string }> return processStartServerJob(job); case 'stop-server': return processStopServerJob(job); + case 'kill-server': + return processKillServerJob(job); + case 'delete-server': + return processDeleteServerJob(job); default: logger.warn({ jobName: job.name }, 'Unknown lifecycle job name'); return { status: 'ignored' }; } } + +async function processKillServerJob(job: Job): Promise<{ status: string }> { + const { serverId } = lifecycleJobSchema.parse(job.data); + await transitionServerStatus(serverId, 'STOPPED', { + reason: 'Kill completed', + correlationId: job.id ?? undefined, + }); + return { status: 'killed' }; +} + +async function processDeleteServerJob(job: Job): Promise<{ status: string }> { + const { serverId } = lifecycleJobSchema.parse(job.data); + await transitionServerStatus(serverId, 'DELETED', { + expectedFrom: 'DELETING', + reason: 'Delete completed', + correlationId: job.id ?? undefined, + }); + return { status: 'deleted' }; +} diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 1c60940..c7605c7 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -7,16 +7,24 @@ import { processDnsSyncTick } from './handlers/dns-sync'; import { processIdleShutdownTick, processUsageMeteringTick } from './handlers/idle-billing'; import { processNodeHealthTick } from './handlers/node-health'; import { processNotificationJob } from './handlers/notifications'; +import { processNotificationDispatchJob } from './handlers/notification-dispatch'; import { processAddonJob } from './handlers/server-addons'; import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups'; import { processLifecycleJob } from './handlers/server-lifecycle'; import { processProvisionServerJob } from './handlers/server-provisioning'; import { processStartQueueTick } from './handlers/start-queue'; import { processWhmcsReconciliationTick, processWhmcsUsagePeriodTick } from './handlers/whmcs-sync'; +import { + processCatalogSyncTick, + processComplianceTick, + processScheduleTick, +} from './handlers/schedules-compliance'; import { startHealthServer } from './health'; import { logger } from './logger'; import { closeRedisConnections } from './redis'; import { + QUEUE_CATALOG_SYNC, + QUEUE_DEAD_LETTER, QUEUE_NOTIFICATIONS, QUEUE_SERVER_ADDONS, QUEUE_SERVER_BACKUPS, @@ -39,6 +47,9 @@ function createQueueWorker( ); if (queueName === QUEUE_NOTIFICATIONS) { + if (job.name === 'dispatch-notification') { + return processNotificationDispatchJob(job); + } return processNotificationJob(job); } @@ -58,6 +69,15 @@ function createQueueWorker( return processLifecycleJob(job); } + if (queueName === QUEUE_CATALOG_SYNC) { + return processCatalogSyncTick(); + } + + if (queueName === QUEUE_DEAD_LETTER) { + logger.warn({ jobId: job.id, data: job.data }, 'Dead-letter job recorded'); + return { recorded: true }; + } + logger.warn({ queue: queueName, jobName: job.name }, 'Unhandled job'); }, { connection }, @@ -117,6 +137,15 @@ async function bootstrap(): Promise { void processWhmcsUsagePeriodTick().catch((error: Error) => { logger.error({ err: error }, 'WHMCS usage period tick failed'); }); + void processScheduleTick().catch((error: Error) => { + logger.error({ err: error }, 'Schedule tick failed'); + }); + void processCatalogSyncTick().catch((error: Error) => { + logger.error({ err: error }, 'Catalog sync tick failed'); + }); + void processComplianceTick().catch((error: Error) => { + logger.error({ err: error }, 'Compliance tick failed'); + }); }, 60_000); const shutdown = async (signal: string): Promise => { diff --git a/apps/worker/src/queues.ts b/apps/worker/src/queues.ts index ad51d7e..bd06f03 100644 --- a/apps/worker/src/queues.ts +++ b/apps/worker/src/queues.ts @@ -3,6 +3,8 @@ export const QUEUE_SERVER_BACKUPS = 'server-backups' as const; export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const; export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const; export const QUEUE_NOTIFICATIONS = 'notifications' as const; +export const QUEUE_CATALOG_SYNC = 'catalog-sync' as const; +export const QUEUE_DEAD_LETTER = 'dead-letter' as const; import { Queue } from 'bullmq'; @@ -14,6 +16,8 @@ export const WORKER_QUEUES = [ QUEUE_SERVER_LIFECYCLE, QUEUE_SERVER_PROVISIONING, QUEUE_NOTIFICATIONS, + QUEUE_CATALOG_SYNC, + QUEUE_DEAD_LETTER, ] as const; export type WorkerQueueName = (typeof WORKER_QUEUES)[number]; @@ -49,3 +53,19 @@ export function getBackupsQueue(): Queue { return backupsQueue; } + +let lifecycleQueue: Queue | null = null; + +export function getLifecycleQueue(): Queue { + if (!lifecycleQueue) { + const config = validateConfig(); + lifecycleQueue = new Queue(QUEUE_SERVER_LIFECYCLE, { + connection: { + url: config.REDIS_URL, + maxRetriesPerRequest: null, + }, + }); + } + + return lifecycleQueue; +} diff --git a/deploy/compose/compose.prod.yml b/deploy/compose/compose.prod.yml new file mode 100644 index 0000000..a309717 --- /dev/null +++ b/deploy/compose/compose.prod.yml @@ -0,0 +1,242 @@ +# HexaHost GameCloud — production Docker Compose stack +# Usage: +# docker network create traefik-network +# cp .env.example .env.prod && edit secrets/domains +# docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d + +name: hexahost-gamecloud-prod + +services: + postgres: + image: postgres:16-alpine + restart: always + env_file: + - ../../.env.prod + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-gamecloud} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB:-gamecloud}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + redis: + image: redis:7-alpine + restart: always + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] + env_file: + - ../../.env.prod + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "20m" + max-file: "3" + + minio: + image: minio/minio:RELEASE.2024-12-18T13-15-44Z + restart: always + command: server /data --console-address ":9001" + env_file: + - ../../.env.prod + environment: + MINIO_ROOT_USER: ${S3_ACCESS_KEY} + MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 30s + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + + minio-init: + image: minio/mc:RELEASE.2024-11-05T11-14-20Z + restart: "no" + depends_on: + minio: + condition: service_healthy + env_file: + - ../../.env.prod + entrypoint: > + /bin/sh -c " + mc alias set local http://minio:9000 $${S3_ACCESS_KEY} $${S3_SECRET_KEY}; + mc mb --ignore-existing local/$${S3_BUCKET:-gamecloud}; + mc anonymous set none local/$${S3_BUCKET:-gamecloud}; + exit 0; + " + environment: + S3_ACCESS_KEY: ${S3_ACCESS_KEY} + S3_SECRET_KEY: ${S3_SECRET_KEY} + S3_BUCKET: ${S3_BUCKET:-gamecloud} + networks: + - gamecloud-internal + + api: + build: + context: ../.. + dockerfile: apps/api/Dockerfile + image: hexahost-gamecloud-api:${GAMECLOUD_IMAGE_TAG:-latest} + restart: always + env_file: + - ../../.env.prod + environment: + NODE_ENV: production + APP_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gamecloud} + REDIS_URL: redis://redis:6379 + S3_ENDPOINT: http://minio:9000 + TRUSTED_PROXY_COUNT: ${TRUSTED_PROXY_COUNT:-1} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + minio: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3001/api/v1/health/live"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + networks: + - gamecloud-internal + - traefik-network + labels: + - traefik.enable=true + - traefik.docker.network=${TRAEFIK_NETWORK:-traefik-network} + - traefik.http.routers.gamecloud-api.rule=Host(`${API_HOST:-api.example.net}`) + - traefik.http.routers.gamecloud-api.entrypoints=websecure + - traefik.http.routers.gamecloud-api.tls=true + - traefik.http.routers.gamecloud-api.tls.certresolver=letsencrypt + - traefik.http.routers.gamecloud-api.middlewares=gamecloud-api-headers@file,gamecloud-rate-limit@file + - traefik.http.routers.gamecloud-api.service=gamecloud-api + - traefik.http.services.gamecloud-api.loadbalancer.server.port=3001 + - traefik.http.routers.gamecloud-api-http.rule=Host(`${API_HOST:-api.example.net}`) + - traefik.http.routers.gamecloud-api-http.entrypoints=web + - traefik.http.routers.gamecloud-api-http.middlewares=gamecloud-redirect-https@file + logging: + driver: json-file + options: + max-size: "100m" + max-file: "10" + + worker: + build: + context: ../.. + dockerfile: apps/worker/Dockerfile + image: hexahost-gamecloud-worker:${GAMECLOUD_IMAGE_TAG:-latest} + restart: always + env_file: + - ../../.env.prod + environment: + NODE_ENV: production + APP_ENV: production + DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gamecloud} + REDIS_URL: redis://redis:6379 + S3_ENDPOINT: http://minio:9000 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3002/health"] + interval: 20s + timeout: 5s + retries: 5 + start_period: 60s + networks: + - gamecloud-internal + logging: + driver: json-file + options: + max-size: "100m" + max-file: "10" + + web: + build: + context: ../.. + dockerfile: apps/web/Dockerfile + image: hexahost-gamecloud-web:${GAMECLOUD_IMAGE_TAG:-latest} + restart: always + env_file: + - ../../.env.prod + environment: + NODE_ENV: production + API_URL: http://api:3001 + depends_on: + api: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:3000/de"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 60s + networks: + - gamecloud-internal + - traefik-network + labels: + - traefik.enable=true + - traefik.docker.network=${TRAEFIK_NETWORK:-traefik-network} + - traefik.http.routers.gamecloud-web.rule=Host(`${WEB_HOST:-panel.example.net}`) + - traefik.http.routers.gamecloud-web.entrypoints=websecure + - traefik.http.routers.gamecloud-web.tls=true + - traefik.http.routers.gamecloud-web.tls.certresolver=letsencrypt + - traefik.http.routers.gamecloud-web.middlewares=gamecloud-web-headers@file + - traefik.http.routers.gamecloud-web.service=gamecloud-web + - traefik.http.services.gamecloud-web.loadbalancer.server.port=3000 + - traefik.http.routers.gamecloud-web-http.rule=Host(`${WEB_HOST:-panel.example.net}`) + - traefik.http.routers.gamecloud-web-http.entrypoints=web + - traefik.http.routers.gamecloud-web-http.middlewares=gamecloud-redirect-https@file + logging: + driver: json-file + options: + max-size: "50m" + max-file: "5" + +volumes: + postgres_data: + name: hexahost-gamecloud-postgres + redis_data: + name: hexahost-gamecloud-redis + minio_data: + name: hexahost-gamecloud-minio + +networks: + gamecloud-internal: + name: hexahost-gamecloud-internal + driver: bridge + traefik-network: + external: true + name: ${TRAEFIK_NETWORK:-traefik-network} diff --git a/deploy/monitoring/grafana/datasources.yml b/deploy/monitoring/grafana/datasources.yml new file mode 100644 index 0000000..e936e4d --- /dev/null +++ b/deploy/monitoring/grafana/datasources.yml @@ -0,0 +1,38 @@ +# Grafana provisioning — HexaHost GameCloud +# Mount to /etc/grafana/provisioning/datasources/datasources.yml + +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false + jsonData: + timeInterval: 15s + httpMethod: POST + + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + editable: false + jsonData: + maxLines: 1000 + derivedFields: + - datasourceUid: prometheus + matcherRegex: "trace_id=(\\w+)" + name: TraceID + url: "$${__value.raw}" + + - name: Prometheus (external) + type: prometheus + access: proxy + url: http://localhost:9090 + isDefault: false + editable: true + jsonData: + timeInterval: 15s + # Optional — use when Grafana runs outside the compose network diff --git a/deploy/monitoring/loki/loki-config.yml b/deploy/monitoring/loki/loki-config.yml new file mode 100644 index 0000000..4e059d9 --- /dev/null +++ b/deploy/monitoring/loki/loki-config.yml @@ -0,0 +1,56 @@ +# Loki configuration stub — HexaHost GameCloud +# Suitable for single-node control plane deployments. Scale to object storage for HA. + +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: info + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +schema_config: + configs: + - from: 2024-01-01 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +storage_config: + tsdb_shipper: + active_index_directory: /loki/tsdb-index + cache_location: /loki/tsdb-cache + filesystem: + directory: /loki/chunks + +limits_config: + retention_period: 720h + ingestion_rate_mb: 16 + ingestion_burst_size_mb: 32 + max_streams_per_user: 10000 + reject_old_samples: true + reject_old_samples_max_age: 168h + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + delete_request_store: filesystem + +analytics: + reporting_enabled: false diff --git a/deploy/traefik/dynamic/gamecloud.yml b/deploy/traefik/dynamic/gamecloud.yml new file mode 100644 index 0000000..f7aadbf --- /dev/null +++ b/deploy/traefik/dynamic/gamecloud.yml @@ -0,0 +1,95 @@ +# HexaHost GameCloud — Traefik dynamic configuration +# Shared middleware and optional file-based routers for the control plane stack. +# Primary routing is defined via Docker labels in deploy/compose/compose.prod.yml. + +http: + middlewares: + gamecloud-redirect-https: + redirectScheme: + scheme: https + permanent: true + + gamecloud-api-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + stsPreload: true + contentTypeNosniff: true + frameDeny: true + referrerPolicy: strict-origin-when-cross-origin + customRequestHeaders: + X-Forwarded-Proto: https + + gamecloud-web-headers: + headers: + stsSeconds: 31536000 + stsIncludeSubdomains: true + contentTypeNosniff: true + frameDeny: true + referrerPolicy: strict-origin-when-cross-origin + customRequestHeaders: + X-Forwarded-Proto: https + + gamecloud-rate-limit: + rateLimit: + average: 100 + burst: 200 + period: 1s + + routers: + # File-provider routers — useful when Traefik runs outside the compose project + # or when you prefer centralised routing. Disable duplicate Docker labels if you + # rely exclusively on these definitions. + gamecloud-api: + rule: Host(`api.example.net`) + entryPoints: + - websecure + service: gamecloud-api + middlewares: + - gamecloud-api-headers + - gamecloud-rate-limit + tls: + certResolver: letsencrypt + + gamecloud-web: + rule: Host(`panel.example.net`) + entryPoints: + - websecure + service: gamecloud-web + middlewares: + - gamecloud-web-headers + tls: + certResolver: letsencrypt + + services: + gamecloud-api: + loadBalancer: + servers: + - url: http://api:3001 + passHostHeader: true + healthCheck: + path: /api/v1/health/live + interval: 15s + timeout: 5s + + gamecloud-web: + loadBalancer: + servers: + - url: http://web:3000 + passHostHeader: true + healthCheck: + path: /de + interval: 15s + timeout: 5s + +tls: + options: + default: + minVersion: VersionTLS12 + cipherSuites: + - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 + - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 + - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305 + - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 diff --git a/deploy/traefik/traefik.yml b/deploy/traefik/traefik.yml new file mode 100644 index 0000000..f5a5185 --- /dev/null +++ b/deploy/traefik/traefik.yml @@ -0,0 +1,59 @@ +# HexaHost GameCloud — Traefik static configuration +# Mount this file read-only when running the Traefik container on the control plane host. + +global: + checkNewVersion: false + sendAnonymousUsage: false + +api: + dashboard: true + insecure: false + +log: + level: INFO + format: json + +accessLog: + format: json + filters: + statusCodes: + - "400-599" + +entryPoints: + web: + address: ":80" + http: + redirections: + entryPoint: + to: websecure + scheme: https + permanent: true + websecure: + address: ":443" + http: + tls: + certResolver: letsencrypt + domains: + - main: "${WEB_HOST:-panel.example.net}" + - main: "${API_HOST:-api.example.net}" + +providers: + docker: + endpoint: unix:///var/run/docker.sock + exposedByDefault: false + network: traefik-network + watch: true + file: + directory: /etc/traefik/dynamic + watch: true + +certificatesResolvers: + letsencrypt: + acme: + email: "${ACME_EMAIL:-ops@example.net}" + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: web + +serversTransport: + insecureSkipVerify: false diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f1edc88..e2fcd2f 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,8 +1,32 @@ # HexaHost GameCloud — Implementation Status -Last updated: 2026-06-26 +Last updated: 2026-07-05 -## Current phase: WHMCS hardening — mTLS + Product mappings ✅ +## Current phase: MVP complete — hardening & production readiness + +### Sprint 3 (2026-07-05) + +| Area | Status | +|------|--------| +| Members UI (server access tab) | Done | +| Organizations / Support / Abuse APIs | Done | +| Compliance export (S3) + account deletion | Done | +| Cron-parser schedules (API + worker) | Done | +| Global idempotency interceptor | Done | +| Feature flag maintenance_mode | Done | +| WHMCS PHP start/stop/restart/backup | Done | +| Worker catalog-sync + notification dispatch | Done | +| Gap-closure DB migration (40 tables) | Done | +| E2E smoke tests extended | Done | + +### Next: Production hardening + +- Penetration test, integration API IP allowlist +- Client-side backup encryption, Seccomp/AppArmor profiles +- ClamAV upload scanning +- Full E2E regression suite in CI + +## Previous phase: WHMCS hardening — mTLS + Product mappings ✅ ### mTLS integration API diff --git a/docs/MASTERPROMPT_GAP_ANALYSIS.md b/docs/MASTERPROMPT_GAP_ANALYSIS.md new file mode 100644 index 0000000..1f3a517 --- /dev/null +++ b/docs/MASTERPROMPT_GAP_ANALYSIS.md @@ -0,0 +1,86 @@ +# Masterprompt Gap-Analyse — HexaHost GameCloud + +Stand: 2026-07-05 (Sprint 1–3 abgeschlossen) + +Vergleich mit `HexaHost_GameCloud_Cursor_Masterprompt.md`. Legende: ✅ erledigt · 🟡 teilweise · ❌ offen · 🔜 Post-MVP + +--- + +## Zusammenfassung + +| Bereich | Status | +|---------|--------| +| Auth, 2FA, Sessions, CSRF | ✅ | +| Server-Lifecycle, Queue, Multi-Node | ✅ | +| Console, Files, Properties, Worlds, Backups | ✅ | +| Add-ons (Modrinth), Catalog | ✅ | +| Billing / Idle-Stop / Credits | ✅ | +| WHMCS Integration + mTLS + Actions | ✅ | +| Members, Schedules, Players, Software-Reinstall | ✅ | +| Notifications (In-App, E-Mail, Webhook, Discord) | ✅ | +| Organizations, Support, Abuse | ✅ | +| Compliance (Export, Deletion) | ✅ | +| Feature Flags, Idempotency, OIDC, OTel | ✅ | +| Radix UI, E2E, apps/docs | ✅ | +| DB-Migration Gap-Closure | ✅ (SQL vorhanden) | + +--- + +## 30. Abgeschlossene Sprint-Prioritäten + +### Sprint 1 (Gap-Closure) +Login-Fix, CSRF, Admin-UI, öffentliche Seiten, Permissions, Server-State, WHMCS Renew/SSO, Deploy-Stubs, 25 Ops-Docs, Container-Härtung Node-Agent. + +### Sprint 2 (Feature-Completion) +Datei-Upload/Archiv, Spieler-Mutationen, Software-Reinstall, Schedules-API, Notifications-REST, OTel, Playwright, FeatureFlags, OIDC, CurseForge, Radix UI, apps/docs, Idempotency, WHMCS actions API. + +### Sprint 3 (Hardening) +- **Members-UI** (`server-access.tsx`) an Members-API +- **Organizations / Support / Abuse** REST-Module +- **Compliance**: S3-Export, Kontolöschung mit Anonymisierung + Server-Delete-Queue +- **Cron-Parser** (API + Worker) für Schedules +- **Idempotency** global registriert +- **Feature Flag** `maintenance_mode` +- **WHMCS PHP**: Start/Stop/Restart/Backup Buttons + ApiClient +- **Worker**: Catalog-Sync (Modrinth), Notification-Dispatch (E-Mail/Webhook/Discord), Compliance-Tick +- **Migration** `20260705160000_gap_closure_tables` (40 Tabellen) +- **Security-Docs**: backup-encryption, container-hardening +- **Impressum**, erweiterte E2E-Smoke-Tests + +--- + +## 31. Post-MVP / Produktions-Härtung + +| Thema | Status | +|-------|--------| +| Kubernetes, Live-Migration | 🔜 | +| Passkeys, Werbenetzwerke | 🔜 | +| ZFS/Ceph Storage-Adapter | 🔜 | +| Backup Client-Side-Verschlüsselung | 🔜 (Bucket-SSE dokumentiert) | +| Seccomp/AppArmor Profile | 🔜 (Baseline Docker-Härtung ✅) | +| ClamAV Upload-Scan | 🔜 | +| Penetrationstest | 🔜 | +| Integration-API IP-Allowlist | 🔜 | + +--- + +## 32. Lokales Setup + +```bash +cp .env.example .env +pnpm dev:infra +pnpm db:migrate # oder pnpm db:push bei frischer DB +pnpm db:seed +pnpm bootstrap:admin -- --email admin@example.com --password 'Relation123!' +pnpm dev +``` + +Login: `admin@example.com` / `Relation123!` + +--- + +## 33. Pflege-Hinweis + +Bei weiteren Änderungen diese Datei und `docs/IMPLEMENTATION_STATUS.md` aktualisieren. + +*Erstellt: 2026-07-05 · Sprint 3: 2026-07-05* diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..765b0ab --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,51 @@ +# GameCloud API documentation + +The HexaHost GameCloud REST API is documented with OpenAPI (Swagger) and served by the API process. + +## Interactive documentation + +When the API is running: + +| Environment | URL | +|-------------|-----| +| Local development | http://localhost:3001/api/v1/docs | +| Production | https://api.example.net/api/v1/docs | + +Replace `api.example.net` with your `API_HOST` / `API_URL` hostname. + +The OpenAPI JSON spec is available at `/api/v1/docs-json` on the same host. + +## Authentication + +| Audience | Method | +|----------|--------| +| Customer panel / users | Session cookie or bearer token after `POST /api/v1/auth/login` | +| WHMCS integration | HMAC-signed requests — see [WHMCS security](../integrations/whmcs/security.md) | +| Node agent | mTLS + enrollment token on WebSocket | +| Admin | Elevated role on session | + +Public health endpoints (`/api/v1/health/*`) require no authentication. + +## Versioning + +All routes are prefixed with `/api/v1/`. Breaking changes require a new major prefix; WHMCS modules declare compatible `APIVersion` in module metadata. + +## Code location + +- Controllers: `apps/api/src/**/*.controller.ts` +- Swagger setup: `apps/api/src/main.ts` (`SwaggerModule.setup('api/v1/docs', ...)`) +- DTOs: colocated with controllers and in `packages/shared` + +## Generating a static export + +```bash +curl -s http://localhost:3001/api/v1/docs-json -o openapi.json +``` + +Use with codegen tools or import into Postman/Insomnia. + +## Related + +- [Control plane operations](../operations/control-plane.md) +- [WHMCS integration](../integrations/whmcs/installation.md) +- [Threat model](../security/threat-model.md) diff --git a/docs/integrations/whmcs/addons.md b/docs/integrations/whmcs/addons.md new file mode 100644 index 0000000..6ae8bc0 --- /dev/null +++ b/docs/integrations/whmcs/addons.md @@ -0,0 +1,56 @@ +# WHMCS addon products + +Bill optional GameCloud features as WHMCS addon products linked to the main Minecraft service. + +## Supported addon types + +| WHMCS addon | GameCloud feature | Module action | +|-------------|-------------------|---------------| +| Extra backup retention | Extended backup slots | Usage / plan metadata sync | +| Dedicated IP (future) | Reserved port/IP on node | Manual / Phase 10 | +| Priority support | No technical change | Billing only | + +Technical addons call GameCloud APIs during `CreateAccount` / `TerminateAccount` on the addon service or via usage records — see [usage billing](../usage-billing.md). + +## Setup + +**Setup → Products/Services → Product Addons → Create New Addon** + +1. Assign to Minecraft parent products +2. Module: **HexaHost GameCloud** (if addon provisioning implemented) or **None** for pure billing markers +3. Map addon ID in GameCloud addon module **Mappings** (addon tab) when available + +## Linking to parent service + +GameCloud correlates via: + +- `externalServiceId` — parent WHMCS service ID +- `externalAddonId` — addon service ID (usage export) + +Ensure parent service is provisioned before addon activation. + +## Lifecycle + +| WHMCS event | Expected behavior | +|-------------|-------------------| +| Addon ordered | Enable feature flag on server | +| Addon suspended | Disable feature; do not delete data | +| Addon terminated | Revert limits; purge addon-specific data | + +## Metered addons + +For usage-based billing (playtime, storage overage): + +1. GameCloud worker aggregates usage +2. WHMCS polls via addon module or server module metric export +3. WHMCS generates invoices — [usage billing](../usage-billing.md) + +## Reconciliation + +Orphaned addons (parent terminated, addon active) appear in **Reconciliation** dashboard. Run dry-run before live fix. + +## Related + +- [Products](products.md) +- [Usage billing](../usage-billing.md) +- [Reconciliation](../reconciliation.md) diff --git a/docs/integrations/whmcs/configurable-options.md b/docs/integrations/whmcs/configurable-options.md new file mode 100644 index 0000000..cd0312a --- /dev/null +++ b/docs/integrations/whmcs/configurable-options.md @@ -0,0 +1,64 @@ +# WHMCS configurable options + +Map WHMCS configurable options to GameCloud server parameters at provision and upgrade time. + +## Overview + +Configurable options let customers choose RAM tiers, Minecraft versions, or software types at order time. The server module reads selected options during `CreateAccount` and `ChangePackage`. + +**Preferred approach:** Use addon product mappings for plan slugs; use configurable options for customer-visible upgrades that WHMCS invoices. + +## Standard option layout + +| Option name | Type | Maps to | +|-------------|------|---------| +| Plan | Dropdown | GameCloud plan slug (via mapping resolver) | +| Minecraft Version | Dropdown | `minecraftVersion` | +| Software | Dropdown | `softwareFamily` | +| Extra Backup Slot | Quantity | WHMCS addon product — see [addons](addons.md) | + +Option order matters if using fallback config option indices in legacy setups. Mappings table overrides text fields when an active row exists. + +## Dropdown values + +### Software family + +Values must match GameCloud enum exactly: + +`VANILLA`, `PAPER`, `PURPUR`, `FABRIC`, `FORGE`, `NEOFORGE`, `QUILT` + +### Minecraft version + +Use semver strings present in GameCloud catalog. Invalid versions fail provisioning with module log error. + +## Upgrade path + +When a customer upgrades via WHMCS configurable option change: + +1. WHMCS invokes `ChangePackage` +2. Module sends new `planSlug` to GameCloud +3. GameCloud resizes limits; may require server restart + +Communicate restart requirement in WHMCS product description. + +## Validation + +The addon **Mappings** page validates plan slugs before save. Configurable option text is **not** validated until order time — typos cause provisioning failures. + +## Pricing + +WHMCS handles all pricing. GameCloud never stores invoice amounts in WHMCS mode (`BILLING_PROVIDER=whmcs`). + +## Example: RAM upgrade option + +1. WHMCS configurable option group "Upgrades" +2. Option "RAM Tier" with values mapping to plan slugs `starter`, `pro`, `enterprise` +3. Product mapping links WHMCS product + option combination to slug via `ProductMapping.php` resolver + +For complex matrices, use separate WHMCS products instead of many conditional options. + +## Related + +- [Products](products.md) +- [Product mappings](../product-mappings.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/integrations/whmcs/configuration.md b/docs/integrations/whmcs/configuration.md new file mode 100644 index 0000000..9ff19ee --- /dev/null +++ b/docs/integrations/whmcs/configuration.md @@ -0,0 +1,77 @@ +# WHMCS configuration + +Global and per-product settings for the HexaHost GameCloud WHMCS integration. + +## Addon module (global) + +**Setup → Addon Modules → HexaHost GameCloud → Configure** + +| Setting | GameCloud env | Description | +|---------|---------------|-------------| +| GameCloud API URL | `API_URL` | Base URL, e.g. `https://api.example.net` — no trailing slash | +| Integration ID | `WHMCS_INTEGRATION_ID` | Unique installation identifier | +| API Secret | `WHMCS_API_SECRET` | HMAC shared secret (≥ 32 chars) | +| Use mTLS client certificate | `INTEGRATION_MTLS_ENABLED` | Enable client cert — see [mtls](../mtls.md) | +| Client certificate path | — | PEM path readable by PHP | +| Client private key path | — | PEM path, restricted permissions | +| CA bundle path | — | Trust anchor for GameCloud API | +| Auto-reconcile on daily cron | — | Dry-run reconciliation after event poll | +| Event poll batch size | — | Default 50 | + +## GameCloud side + +```env +BILLING_PROVIDER=whmcs +WHMCS_INTEGRATION_ID=prod-whmcs-main +WHMCS_API_SECRET= +WHMCS_WEBHOOK_SECRET= +INTEGRATION_MTLS_ENABLED=true +APP_URL=https://panel.example.net +API_URL=https://api.example.net +``` + +Restart `api` after credential changes. + +## Server module (per product) + +Each WHMCS product using module `hexagamecloud` needs: + +1. **Module Name:** HexaHost GameCloud +2. **Server:** Server entry pointing to API hostname +3. **Config options** or addon mappings — see [products](products.md) and [product mappings](../product-mappings.md) + +### Default config options + +| Option | Purpose | +|--------|---------| +| GameCloud Plan Slug | Fallback plan if no mapping row | +| Minecraft Version | Default version at provision | +| Software Family | VANILLA, PAPER, etc. | + +Prefer **addon mappings** over raw config option text for production. + +## Cron + +WHMCS daily cron invokes: + +- Pending event poll from GameCloud +- Optional reconciliation dry-run + +Ensure WHMCS cron runs at least every 5 minutes for timely suspensions; daily hook handles bulk sync. + +## Reverse proxy + +If WHMCS reaches GameCloud through nginx/Traefik, preserve headers for mTLS fingerprint forwarding. Example: `deploy/nginx/integration-mtls.conf.example`. + +## Staging checklist + +- [ ] Integration ID unique per environment +- [ ] Test order on hidden WHMCS product +- [ ] SSO login to panel works ([sso](../sso.md)) +- [ ] Suspend / unsuspend updates server state within 60s + +## Related + +- [Installation](installation.md) +- [Security](security.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/integrations/whmcs/installation.md b/docs/integrations/whmcs/installation.md new file mode 100644 index 0000000..b28f17a --- /dev/null +++ b/docs/integrations/whmcs/installation.md @@ -0,0 +1,93 @@ +# WHMCS installation + +Install the HexaHost GameCloud provisioning and addon modules on WHMCS 8.x+. + +## Prerequisites + +- WHMCS with SSL (HTTPS admin and client area) +- GameCloud control plane deployed (`deploy/compose/compose.prod.yml`) +- `BILLING_PROVIDER=whmcs` on GameCloud +- Matching credentials prepared (`WHMCS_INTEGRATION_ID`, `WHMCS_API_SECRET`) + +## Package contents + +Source tree: `integrations/whmcs/` + +| Component | WHMCS path | +|-----------|------------| +| Server module | `/modules/servers/hexagamecloud/` | +| Addon module | `/modules/addons/hexagamecloud/` | +| Hooks | `/includes/hooks/hexagamecloud.php` | + +Build a distributable ZIP: + +```bash +cd integrations/whmcs/packaging +./build-package.sh +``` + +## Installation steps + +### 1. Copy server module + +```bash +cp -r integrations/whmcs/modules/servers/hexagamecloud /path/to/whmcs/modules/servers/ +``` + +**Setup → Products/Services → Servers → Create New Server** + +- Type: **HexaHost GameCloud** +- Hostname: `api.example.net` (GameCloud API host only — no path) +- Username / Password: leave empty (credentials are per-product module settings) + +### 2. Copy addon module + +```bash +cp -r integrations/whmcs/modules/addons/hexagamecloud /path/to/whmcs/modules/addons/ +``` + +**Setup → Addon Modules → HexaHost GameCloud → Activate** + +Configure global settings — see [configuration](configuration.md). + +### 3. Install hooks + +```bash +cp integrations/whmcs/hooks/hexagamecloud.php /path/to/whmcs/includes/hooks/ +``` + +Hooks enable daily event polling and optional auto-reconciliation. + +### 4. Configure GameCloud + +On the control plane `.env.prod`: + +```env +BILLING_PROVIDER=whmcs +WHMCS_INTEGRATION_ID=prod-whmcs-main +WHMCS_API_SECRET= +INTEGRATION_MTLS_ENABLED=true +``` + +Restart API after changes. + +### 5. Verify connectivity + +In WHMCS: **Addons → HexaHost GameCloud → Dashboard** + +Expected: green health, catalog plan count, zero pending critical events. + +## File permissions + +WHMCS web user must read module PHP files. Do not make `lib/` or `templates/` web-writable. + +## Multi-environment + +Use separate `WHMCS_INTEGRATION_ID` values for staging and production GameCloud installations. Never point staging WHMCS at production GameCloud. + +## Related + +- [Configuration](configuration.md) +- [Products](products.md) +- [Security](security.md) +- [Addon module](../addon-module.md) diff --git a/docs/integrations/whmcs/products.md b/docs/integrations/whmcs/products.md new file mode 100644 index 0000000..78f9953 --- /dev/null +++ b/docs/integrations/whmcs/products.md @@ -0,0 +1,71 @@ +# WHMCS products + +Creating WHMCS products that provision HexaHost GameCloud Minecraft servers. + +## Product setup + +**Setup → Products/Services → Create New Product** + +| Field | Value | +|-------|-------| +| Product type | Other | +| Module | HexaHost GameCloud (`hexagamecloud`) | +| Server group | Server entry with GameCloud API hostname | + +## Plan mapping + +Each product must resolve to a GameCloud **plan slug** (defines RAM, CPU, backup slots, etc.). + +**Recommended:** Configure via addon **Mappings** tab — [product mappings](../product-mappings.md) + +**Fallback:** Set config option **GameCloud Plan Slug** on the product module settings. + +Validate slugs against live catalog: + +```http +GET /api/v1/catalog/plans +``` + +Only active plans appear in the addon mapping dropdown. + +## Example products + +| WHMCS product name | Plan slug | Target audience | +|--------------------|-----------|-----------------| +| Minecraft Starter | `starter` | 2 GB RAM, 10 slots | +| Minecraft Pro | `pro` | 4 GB RAM, backup included | +| Minecraft Enterprise | `enterprise` | Custom limits | + +Plan slugs are defined in GameCloud admin/catalog — not in WHMCS. + +## Software defaults + +Set per mapping or config options: + +- **Minecraft Version** — e.g. `1.21.1` +- **Software Family** — `VANILLA`, `PAPER`, `PURPUR`, `FABRIC`, `FORGE`, etc. + +Customers may change software via panel after provision if plan allows. + +## Billing cycle + +WHMCS owns billing cycles. GameCloud receives `externalServiceId` and syncs lifecycle (suspend on overdue invoice via WHMCS module actions or events). + +Usage-based overages: see [usage billing](../usage-billing.md). + +## Client area + +The server module renders status and **Login to Panel** (SSO). Domain field is used as display name default (`server-{serviceId}` if empty). + +## Testing + +1. Create admin-only product group +2. Place test order with 100% discount +3. Confirm `CreateAccount` success in module log +4. SSO into panel and verify server RUNNING or STARTING + +## Related + +- [Configurable options](configurable-options.md) +- [Addons](addons.md) +- [Installation](installation.md) diff --git a/docs/integrations/whmcs/security.md b/docs/integrations/whmcs/security.md new file mode 100644 index 0000000..07f188b --- /dev/null +++ b/docs/integrations/whmcs/security.md @@ -0,0 +1,79 @@ +# WHMCS integration security + +Security controls for the API-only link between WHMCS and HexaHost GameCloud. + +## Trust model + +- WHMCS is the **commercial system of record** (payments, invoices) +- GameCloud is the **technical system of record** (servers, nodes) +- Integration is **one-way authenticated API** — no shared database + +## Authentication layers + +### 1. HMAC request signing (required) + +Every request to `/api/v1/integrations/whmcs/*` includes: + +| Header | Purpose | +|--------|---------| +| `X-HGC-Integration-Id` | Installation identifier | +| `X-HGC-Timestamp` | Unix epoch seconds | +| `X-HGC-Signature` | HMAC-SHA256 of canonical request | + +Secret: `WHMCS_API_SECRET` — rotate via [secrets](../../security/secrets.md). + +Clock skew tolerance: ± 300 seconds. Sync NTP on both hosts. + +### 2. mTLS (production recommended) + +When `INTEGRATION_MTLS_ENABLED=true`: + +- Client certificate required from WHMCS host +- Fingerprint registered in GameCloud installation record +- See [mtls](../mtls.md) + +### 3. Network controls + +- Allowlist WHMCS egress IP at firewall or Traefik +- No public exposure of WHMCS admin URL without 2FA +- API only on HTTPS (`api.example.net`) + +## WHMCS hardening + +| Control | Recommendation | +|---------|----------------| +| Admin 2FA | Required for all staff | +| File permissions | Module PHP not writable by web user | +| API Secret storage | WHMCS encrypted settings storage | +| Module logs | Restrict **Utilities → Logs** access | +| Hooks file | `includes/hooks/hexagamecloud.php` owned by root/deploy user | + +## Least privilege + +WHMCS server module credentials are **not** stored in WHMCS server username/password fields — only in addon encrypted settings. GameCloud API keys for customers are separate from integration secret. + +## Audit + +GameCloud logs integration calls with integration ID and action. WHMCS module log redacts secrets. Correlate by timestamp and `externalServiceId`. + +## Incident response + +On suspected secret leak: + +1. Rotate `WHMCS_API_SECRET` on GameCloud first, then WHMCS addon +2. Review module log for anomalous provisioning +3. Run reconciliation dry-run +4. Re-register mTLS fingerprint if cert compromised + +See [incident response](../../operations/incident-response.md). + +## Compliance + +- PCI: WHMCS handles card data; GameCloud never receives PAN +- GDPR: Customer PII flows in `upsertClient` — subject to [data retention](../../security/data-retention.md) + +## Related + +- [Configuration](configuration.md) +- [mTLS](../mtls.md) +- [Troubleshooting](troubleshooting.md) diff --git a/docs/integrations/whmcs/troubleshooting.md b/docs/integrations/whmcs/troubleshooting.md new file mode 100644 index 0000000..8933091 --- /dev/null +++ b/docs/integrations/whmcs/troubleshooting.md @@ -0,0 +1,101 @@ +# WHMCS troubleshooting + +Common integration failures between WHMCS and HexaHost GameCloud. + +## Diagnostic tools + +| Tool | Location | +|------|----------| +| WHMCS module log | Utilities → Logs → Module Log | +| GameCloud addon dashboard | Addons → HexaHost GameCloud | +| API health | `GET https://api.example.net/api/v1/health/live` | +| OpenAPI | `/api/v1/docs` — [API README](../../api/README.md) | +| Reconciliation | Addon → Reconciliation tab | + +## Authentication errors + +### `Invalid signature` / 401 + +- Confirm `WHMCS_API_SECRET` matches exactly on both sides (no trailing newline) +- Check `WHMCS_INTEGRATION_ID` matches addon **Integration ID** +- Verify server clock (NTP): `X-HGC-Timestamp` skew > 5 min fails +- Ensure **GameCloud API URL** has no trailing slash + +### mTLS / certificate errors + +- `INTEGRATION_MTLS_ENABLED=true` but fingerprint not registered → register in addon **mTLS** tab +- Wrong CA bundle on WHMCS → point to GameCloud CA PEM +- Reverse proxy not forwarding `X-HGC-Client-Cert-Fingerprint` → see [mtls](../mtls.md) + +## Provisioning failures + +### `CreateAccount` returns error string + +1. Read WHMCS module log full message +2. Common causes: + - Invalid plan slug — fix [product mapping](../product-mappings.md) + - Catalog plan inactive + - No eligible game node (`NO_NODE_AVAILABLE`) + - Invalid Minecraft version for software family + +### Server stuck Pending in WHMCS + +- GameCloud provision may have succeeded — check addon dashboard service count +- Run reconciliation dry-run +- Manual SSO to panel to verify server exists + +## Lifecycle sync + +### Suspend / unsuspend not applied + +- WHMCS module action failed silently — check module log +- GameCloud worker backlog — check Redis / worker logs +- Event poll not running — verify WHMCS cron active + +### Terminate left data on node + +- Terminate API may have failed mid-flight — admin force-terminate in panel +- Node offline during terminate — retry when node ONLINE + +## SSO issues + +See dedicated [SSO guide](../sso.md). Typical fixes: + +- `APP_URL` mismatch between GameCloud and expected redirect +- Clock skew +- Service not linked to correct `externalServiceId` + +## Usage billing gaps + +- Usage export cron not running +- Mismatch between WHMCS service ID and GameCloud external ID — reconciliation + +## Network + +```bash +# From WHMCS host +curl -v https://api.example.net/api/v1/health/live +openssl s_client -connect api.example.net:443 -servername api.example.net +``` + +Firewall must allow outbound 443 from WHMCS to GameCloud API. + +## Enable debug logging + +WHMCS: enable module debug in **System Settings → General Settings** (temporarily). + +GameCloud: set `LOG_LEVEL=debug` on API, reproduce, revert to `info`. + +## Escalation data to collect + +- WHMCS service ID and timestamp of failure +- Module log entry (redact secrets) +- GameCloud request ID from API response header if present +- Integration ID and API version + +## Related + +- [Security](security.md) +- [Configuration](configuration.md) +- [Incident response](../../operations/incident-response.md) +- [Reconciliation](../reconciliation.md) diff --git a/docs/integrations/whmcs/upgrades.md b/docs/integrations/whmcs/upgrades.md new file mode 100644 index 0000000..c322128 --- /dev/null +++ b/docs/integrations/whmcs/upgrades.md @@ -0,0 +1,74 @@ +# WHMCS module upgrades + +Upgrade paths for `integrations/whmcs` server and addon modules alongside GameCloud API releases. + +## Version compatibility + +Check release notes for minimum pairs: + +| GameCloud API | Server module | Addon module | +|---------------|---------------|--------------| +| 1.0.x | 1.0.x | 1.0.x | +| 1.1.x | 1.1.x | 1.1.0+ (SQL migrations) | + +Module metadata declares `APIVersion` in `hexagamecloud_MetaData()`. + +## Upgrade order + +1. **GameCloud API** — deploy new control plane ([upgrades](../../operations/upgrades.md)) +2. **Database migrations** — addon SQL in `modules/addons/hexagamecloud/sql/upgrade-*.sql` +3. **WHMCS addon module** — copy files, run upgrade hook +4. **WHMCS server module** — copy files (usually no DB change) +5. **Verify** — dashboard health, test suspend/unsuspend + +Never upgrade WHMCS modules before the API when release notes specify API-first ordering. + +## Addon SQL migrations + +WHMCS runs `hexagamecloud_upgrade()` on version bump. Manual apply if needed: + +```bash +mysql whmcs < integrations/whmcs/modules/addons/hexagamecloud/sql/upgrade-1.1.0.sql +``` + +Back up WHMCS database before upgrade. + +## File deployment + +```bash +# From packaging script output or git +cp -r integrations/whmcs/modules/addons/hexagamecloud /path/to/whmcs/modules/addons/ +cp -r integrations/whmcs/modules/servers/hexagamecloud /path/to/whmcs/modules/servers/ +cp integrations/whmcs/hooks/hexagamecloud.php /path/to/whmcs/includes/hooks/ +``` + +Clear WHMCS template cache if admin UI looks stale. + +## Zero-downtime considerations + +- Running game servers unaffected by module file update +- Brief API restart during GameCloud upgrade may delay WHMCS module calls — retry logic in `ApiClient.php` handles transient 502 +- Place WHMCS in maintenance mode only if API breaking change requires it + +## Rollback + +1. Restore previous module files from backup +2. Roll back GameCloud images if API incompatible +3. Restore WHMCS DB if SQL migration ran + +Product mappings table is forward-compatible unless release notes say otherwise. + +## Packaging + +```bash +cd integrations/whmcs/packaging +./build-package.sh +``` + +Distribute ZIP to production WHMCS via change-controlled deploy. + +## Related + +- [Installation](installation.md) +- [Troubleshooting](troubleshooting.md) +- [Control plane upgrades](../../operations/upgrades.md) diff --git a/docs/operations/backup-restore.md b/docs/operations/backup-restore.md new file mode 100644 index 0000000..f804ca0 --- /dev/null +++ b/docs/operations/backup-restore.md @@ -0,0 +1,108 @@ +# Backup and restore + +This runbook covers backing up and restoring HexaHost GameCloud control plane data. Game server world data is backed up separately via the in-product backup feature (stored in object storage). + +## Scope + +| Component | Method | Frequency | +|-----------|--------|-----------| +| PostgreSQL | `pg_dump` + WAL archiving | Daily full; continuous WAL (recommended) | +| Redis | RDB snapshot (`BGSAVE`) | Hourly if non-transient keys matter | +| Object storage (MinIO/S3) | Replication or `mc mirror` | Continuous or daily | +| Traefik ACME | Copy `acme.json` | After each cert issue | +| `.env.prod` / secrets | Encrypted off-site vault | On change | +| WHMCS mappings | DB export of `mod_hexagamecloud_*` | Weekly | + +## PostgreSQL backup + +### Logical dump (single host) + +```bash +docker exec hexahost-gamecloud-prod-postgres-1 \ + pg_dump -U gamecloud -Fc gamecloud > "gamecloud-$(date +%F).dump" +``` + +Store dumps encrypted off-site. Retain at least 30 daily backups for production. + +### Restore from dump + +1. Stop API and worker to prevent writes: + + ```bash + docker compose -f deploy/compose/compose.prod.yml stop api worker + ``` + +2. Drop and recreate database (destructive): + + ```bash + docker exec -i hexahost-gamecloud-prod-postgres-1 \ + psql -U gamecloud -c "DROP DATABASE gamecloud WITH (FORCE);" + docker exec -i hexahost-gamecloud-prod-postgres-1 \ + psql -U gamecloud -c "CREATE DATABASE gamecloud OWNER gamecloud;" + ``` + +3. Restore: + + ```bash + cat gamecloud-2026-07-01.dump | docker exec -i hexahost-gamecloud-prod-postgres-1 \ + pg_restore -U gamecloud -d gamecloud --no-owner --role=gamecloud + ``` + +4. Start services and verify: + + ```bash + docker compose -f deploy/compose/compose.prod.yml start api worker + curl -s https://api.example.net/api/v1/health/live + ``` + +### Point-in-time recovery (PITR) + +For managed PostgreSQL or self-hosted WAL archiving, enable `archive_mode` and restore to a timestamp before the incident. Document your WAL retention in [disaster-recovery](disaster-recovery.md). + +## Redis backup + +Redis holds job queues and ephemeral cache. For queue durability during maintenance: + +```bash +docker exec hexahost-gamecloud-prod-redis-1 redis-cli BGSAVE +docker cp hexahost-gamecloud-prod-redis-1:/data/dump.rdb ./redis-$(date +%F).rdb +``` + +Restore by stopping Redis, replacing `dump.rdb`, and restarting. Expect in-flight jobs to be reprocessed or dead-lettered. + +## Object storage backup + +```bash +mc alias set prod http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" +mc mirror prod/gamecloud /backup/gamecloud-$(date +%F)/ +``` + +Verify checksums on a sample of backup objects (`backups/*/manifest.json`). + +## Game server backups (tenant data) + +Customer-initiated backups are stored under `s3://gamecloud/backups/{serverId}/`. Restore through the panel or admin API — not via PostgreSQL restore alone. + +## Pre-restore checklist + +- [ ] Incident ticket with restore target time documented +- [ ] WHMCS placed in maintenance mode if billing sync is active +- [ ] Node agents will reconnect automatically after API restore +- [ ] DNS records remain valid (no restore needed for RFC2136 provider state in DB) + +## Verification after restore + +1. Admin login and random server detail page +2. WHMCS addon dashboard health (`Addons → HexaHost GameCloud`) +3. Worker queue processing (`failed` jobs near zero) +4. Sample backup download from object storage + +## Automation + +Integrate dumps with your existing backup tool (restic, Borg, Velero). Ansible playbooks in `deploy/ansible/` provision hosts; schedule backups via cron or systemd timers on `cp-db` or the compose host. + +## Related + +- [Disaster recovery](disaster-recovery.md) +- [Object storage](object-storage.md) +- [Data retention](../security/data-retention.md) diff --git a/docs/operations/disaster-recovery.md b/docs/operations/disaster-recovery.md new file mode 100644 index 0000000..d12106a --- /dev/null +++ b/docs/operations/disaster-recovery.md @@ -0,0 +1,80 @@ +# Disaster recovery + +Recovery objectives and procedures when a HexaHost GameCloud control plane or game node is lost. + +## Recovery objectives (default targets) + +| Tier | RPO | RTO | Notes | +|------|-----|-----|-------| +| Control plane (API, DB) | 1 h | 4 h | With WAL + hourly Redis snapshot | +| Object storage (backups) | 15 min | 4 h | With cross-region replication | +| Single game node | 0 (running servers) | 30 min | Reschedule + DNS unchanged if edge IP stable | +| Full region loss | 24 h | 24 h | Requires warm standby or restore from off-site | + +Adjust these in your internal SLA and document approved deviations. + +## Failure scenarios + +### Control plane host lost + +1. Provision replacement via `deploy/ansible/control-plane.yml` +2. Restore PostgreSQL from latest dump + WAL ([backup-restore](backup-restore.md)) +3. Restore MinIO data or repoint `S3_*` to replica bucket +4. Deploy compose: `deploy/compose/compose.prod.yml` +5. Restore Traefik `acme.json` or re-issue certificates +6. Verify WHMCS integration (HMAC secret unchanged) + +### Database corruption + +1. Stop writes (`api`, `worker`) +2. Restore to last known-good PITR timestamp +3. Run WHMCS reconciliation dry-run from addon module +4. Review audit log gap for the corruption window + +### Object storage unavailable + +- API continues serving metadata; backup restore and file uploads fail +- Fail over to replica endpoint or restore MinIO volume from snapshot +- Workers retry S3 operations with exponential backoff + +### All game nodes offline + +- Customers cannot start servers; running containers on nodes may continue until stopped +- Edge gateway returns connection errors for join-to-start +- Restore nodes in priority order; scheduler assigns new provisions when nodes return + +### WHMCS host lost + +GameCloud remains authoritative for technical state. Reinstall WHMCS module from `integrations/whmcs/`, restore WHMCS database, re-import product mappings from backup export. + +## Standby strategy (recommended) + +| Component | Standby approach | +|-----------|------------------| +| PostgreSQL | Streaming replica or managed HA | +| Redis | Sentinel or managed Redis | +| S3 | Cross-region replication | +| Control plane VMs | IaC templates in `deploy/ansible/` | +| Traefik certs | Shared `acme.json` on persistent volume | + +## Communication + +During DR: + +1. Status page update (if applicable) +2. Internal war room with roles: coordinator, DB, network, WHMCS +3. Customer comms after RTO estimate is known — WHMCS can send mass email + +## DR drill checklist (quarterly) + +- [ ] Restore PostgreSQL dump to staging environment +- [ ] Restore sample backup from object storage to test server +- [ ] Fail over DNS to standby edge IP (dry run) +- [ ] WHMCS provisioning test order on staging +- [ ] Document actual RPO/RTO vs targets + +## Related + +- [Backup and restore](backup-restore.md) +- [Server migration](server-migration.md) +- [Incident response](incident-response.md) diff --git a/docs/operations/incident-response.md b/docs/operations/incident-response.md new file mode 100644 index 0000000..e16d0aa --- /dev/null +++ b/docs/operations/incident-response.md @@ -0,0 +1,84 @@ +# Incident response + +Operational playbook for security and availability incidents affecting HexaHost GameCloud. + +## Severity levels + +| Level | Examples | Response time | Examples | +|-------|----------|---------------|----------| +| SEV-1 | API down, data breach suspected, all nodes offline | 15 min | Full platform outage | +| SEV-2 | Single node down, WHMCS provision failures, DNS sync broken | 1 h | Partial customer impact | +| SEV-3 | Elevatoration, non-critical bug, one customer server stuck | Next business day | Limited blast radius | + +## Initial response (all severities) + +1. **Acknowledge** — Assign incident commander and scribe +2. **Triage** — Customer-facing vs internal-only impact +3. **Communicate** — Internal channel + status page if SEV-1/2 +4. **Preserve evidence** — Do not reboot hosts before log capture if security-related + +## Common incidents + +### API unavailable (502/503) + +```bash +docker compose -f deploy/compose/compose.prod.yml ps +docker compose -f deploy/compose/compose.prod.yml logs --tail=200 api +curl -v https://api.example.net/api/v1/health/live +``` + +Check Traefik routing ([traefik](traefik.md)), PostgreSQL connectivity, disk full. + +### Worker queue backlog + +Inspect Redis queue depth and failed jobs in logs. Scale worker container or restart after fixing root cause (DNS provider down, S3 errors). + +### Node heartbeat stale + +Nodes not seen > 90s trigger alerts. Verify node-agent systemd status, outbound firewall to `wss://api.example.net/api/v1/nodes/ws`, certificate expiry. + +### Suspected credential leak + +1. Rotate affected secrets immediately ([secrets](../security/secrets.md)): + - `SESSION_SECRET` (invalidates all sessions) + - `WHMCS_API_SECRET` per installation + - `NODE_TOKEN` for compromised node + - `S3_ACCESS_KEY` / database password +2. Review audit logs and WHMCS module call logs +3. Enable mTLS if not already active for WHMCS integration + +### Tenant isolation concern (SEV-1) + +1. Disable affected API endpoints if exploit is active +2. Capture request IDs from audit log +3. Engage security lead; reference [threat model](../security/threat-model.md) T21 + +## WHMCS-specific + +Provisioning failures often appear in WHMCS **Utilities → Logs → Module Log**. Cross-check GameCloud API logs with `X-HGC-Integration-Id` header. + +Run reconciliation dry-run before live fixes: [reconciliation](../integrations/whmcs/reconciliation.md). + +## Post-incident + +Within 5 business days: + +- [ ] Timeline document (UTC) +- [ ] Root cause (5 whys) +- [ ] Action items with owners +- [ ] Update runbooks if gaps found + +## Contacts and escalation + +Document internally: + +- On-call rotation +- WHMCS admin contact +- DNS provider support +- Hosting provider (game nodes) + +## Related + +- [Disaster recovery](disaster-recovery.md) +- [Threat model](../security/threat-model.md) +- [WHMCS troubleshooting](../integrations/whmcs/troubleshooting.md) diff --git a/docs/operations/node-drain.md b/docs/operations/node-drain.md new file mode 100644 index 0000000..0d688e9 --- /dev/null +++ b/docs/operations/node-drain.md @@ -0,0 +1,101 @@ +# Game node drain + +Gracefully remove a game node from scheduling before maintenance, decommissioning, or migration. + +## When to drain + +- OS kernel / Docker upgrade on the node +- Hardware maintenance or rack move +- Shrinking cluster capacity +- Replacing a node with new hardware ([server migration](server-migration.md)) + +Do **not** drain the last node in a region if active servers have no migration target. + +## Procedure + +### 1. Set node to DRAINING + +Via admin API: + +```http +PATCH /api/v1/admin/nodes/{nodeId} +Authorization: Bearer +Content-Type: application/json + +{ "status": "DRAINING" } +``` + +The scheduler stops placing new servers on this node. Existing running servers continue until stopped by users or idle shutdown. + +### 2. Wait for natural shutdown (optional) + +If maintenance window allows, wait for idle shutdown policy to stop inactive servers. Monitor: + +```sql +SELECT id, status, "nodeId" FROM "GameServer" WHERE "nodeId" = '' AND status = 'RUNNING'; +``` + +### 3. Migrate or stop remaining servers + +For each running server: + +- **Preferred:** Customer stops server via panel; reprovision on another node on next start (scheduler picks eligible node) +- **Admin:** Force stop via admin API, then start — triggers reschedule if original node is DRAINING +- **Maintenance:** Communicate window to affected customers + +### 4. Confirm empty node + +```sql +SELECT COUNT(*) FROM "GameServer" WHERE "nodeId" = '' AND status IN ('RUNNING', 'STARTING'); +``` + +Expected: `0` before maintenance on Docker host. + +### 5. Stop node-agent + +```bash +sudo systemctl stop hgc-node-agent +``` + +Configured in `deploy/systemd/node-agent.service`. + +### 6. Perform maintenance + +Apply updates via `deploy/ansible/game-node.yml` or manual steps. See [game node operations](game-node.md). + +### 7. Return to service or decommission + +**Return:** + +```http +PATCH /api/v1/admin/nodes/{nodeId} +{ "status": "ONLINE" } +``` + +```bash +sudo systemctl start hgc-node-agent +``` + +**Decommission:** + +1. Revoke enrollment token in control plane +2. Remove `GameNode` row or mark `OFFLINE` permanently +3. Wipe `/var/lib/hgc-node` data if disks are reused + +## Edge cases + +| Situation | Action | +|-----------|--------| +| Server stuck STARTING during drain | Admin stop + investigate node-agent logs | +| Port range exhausted on other nodes | Add node or expand range before drain | +| Node loses connectivity mid-drain | Servers marked UNKNOWN; reconcile when agent returns | + +## Automation + +Future: automated drain API with `--wait-empty` timeout. Until then, use SQL + admin API as above. + +## Related + +- [Game node operations](game-node.md) +- [Server migration](server-migration.md) +- [Upgrades](upgrades.md) diff --git a/docs/operations/object-storage.md b/docs/operations/object-storage.md new file mode 100644 index 0000000..0c75c8b --- /dev/null +++ b/docs/operations/object-storage.md @@ -0,0 +1,90 @@ +# Object storage operations + +HexaHost GameCloud stores backups, world exports, mod archives, and other large blobs in S3-compatible object storage. Development uses MinIO via `deploy/compose/compose.dev.yml`; production uses MinIO or an external S3 provider. + +## Environment variables + +| Variable | Description | +|----------|-------------| +| `S3_ENDPOINT` | Internal URL, e.g. `http://minio:9000` in compose | +| `S3_REGION` | Region identifier (`us-east-1` for MinIO) | +| `S3_BUCKET` | Primary bucket (default `gamecloud`) | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | Credentials — rotate regularly | +| `S3_FORCE_PATH_STYLE` | `true` for MinIO; `false` for AWS S3 | + +## Production (MinIO in compose) + +The production stack in `deploy/compose/compose.prod.yml` runs MinIO on the internal network only — no public ports. API and worker reach it at `http://minio:9000`. + +Initial bucket creation is handled by `minio-init` on first deploy: + +```bash +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up minio-init +``` + +### Admin console access + +Do not expose the MinIO console to the public internet. Options: + +- SSH tunnel: `ssh -L 9001:127.0.0.1:9001 cp-01` +- VPN-only route to the control plane management network +- Separate MinIO deployment with IAM and audit logging + +## External S3 (AWS, Wasabi, Hetzner Object Storage) + +Point `S3_ENDPOINT` at the provider URL and set `S3_FORCE_PATH_STYLE=false` where appropriate. Create the bucket manually and apply a bucket policy that: + +- Denies public read/write +- Allows only the GameCloud service principal (access key) +- Enables versioning for backup objects +- Optionally enforces SSE-S3 or SSE-KMS + +Remove the `minio` and `minio-init` services from compose when using external storage. + +## Object layout + +| Prefix | Content | Retention | +|--------|---------|-----------| +| `backups/{serverId}/` | Scheduled server backups | Per plan + [data retention policy](../security/data-retention.md) | +| `worlds/{serverId}/` | World export ZIPs | 7 days after download link expiry | +| `uploads/{serverId}/` | User file uploads (staging) | Cleaned after successful install | +| `catalog/` | Cached mod/plugin artifacts | Managed by catalog sync jobs | + +## Health and monitoring + +MinIO health endpoint: `GET /minio/health/live` + +Monitor: + +- Bucket size growth (per tenant and total) +- Failed PUT/GET rates from API logs +- Disk usage on MinIO volume `hexahost-gamecloud-minio` + +## Backup of the storage layer + +For self-hosted MinIO: + +1. Enable bucket versioning +2. Replicate to a second bucket or provider (MinIO site replication or `mc mirror`) +3. Include object storage in [backup-restore](backup-restore.md) runbooks + +For external S3, rely on provider replication and lifecycle rules; document RPO/RTO in [disaster-recovery](disaster-recovery.md). + +## Troubleshooting + +### `NoSuchBucket` + +Run `minio-init` or create the bucket manually with the configured `S3_BUCKET` name. + +### Signature errors + +Verify clock sync (NTP) on API/worker hosts. Check `S3_FORCE_PATH_STYLE` matches the provider. + +### Slow backup uploads + +Check network between game nodes (backup source) and storage. Large backups stream from node-agent → API → S3; consider dedicated storage nodes for high-volume deployments. + +## Related + +- [Backup and restore](backup-restore.md) +- [Secrets management](../security/secrets.md) diff --git a/docs/operations/server-migration.md b/docs/operations/server-migration.md new file mode 100644 index 0000000..d1dffd1 --- /dev/null +++ b/docs/operations/server-migration.md @@ -0,0 +1,79 @@ +# Server and host migration + +Moving HexaHost GameCloud workloads between hosts: control plane relocation, game node replacement, and WHMCS host migration. + +## Control plane migration + +Move from `cp-01-old` to `cp-01-new`: + +1. **Prepare new host** — `ansible-playbook -i inventories/production deploy/ansible/control-plane.yml` +2. **Stop writes on old host** — `docker compose stop api worker` +3. **Backup** — PostgreSQL dump, Redis RDB, MinIO mirror, `/opt/hexahost-gamecloud/.env.prod`, Traefik `acme.json` +4. **Transfer** — Secure copy to new host (rsync over SSH, encrypted archive) +5. **Restore** — Follow [backup-restore](backup-restore.md) on new host +6. **Update DNS** — `panel.example.net` and `api.example.net` A/AAAA records to new IP +7. **Start stack** — `docker compose -f deploy/compose/compose.prod.yml up -d` +8. **Verify** — Health checks, WHMCS addon dashboard, node WebSocket reconnections +9. **Decommission old host** — Secure wipe after 72h parallel run (optional) + +WHMCS requires no URL change if hostnames are unchanged. Update `GameCloud API URL` in addon settings only if `api.example.net` changes. + +## Game node migration + +Replace `node-01` with `node-02` while preserving customer servers: + +### Add replacement node first + +1. Create `GameNode` in admin API +2. Deploy agent: `deploy/ansible/game-node.yml` +3. Confirm `ONLINE` status + +### Drain old node + +Follow [node-drain](node-drain.md). Running servers must stop and start on the new node. + +### Data considerations + +- World data lives in Docker volumes on the node — **not** automatically migrated +- Customers should create a backup before migration window +- Admin can trigger backup via API, then restore after server starts on new node + +### DNS + +Join hostnames point to the **edge gateway**, not individual nodes. No DNS change required for node migration unless edge IP changes. + +## Edge gateway migration + +If `EDGE_PUBLIC_HOST` changes: + +1. Update `RFC2136` records or manual DNS for `*.play.example.net` +2. Deploy edge gateway on new IP +3. Worker DNS sync reconciles `server_dns_records` within 60s + +## WHMCS host migration + +1. Export WHMCS database and `mod_hexagamecloud_*` tables +2. Copy `integrations/whmcs/modules/` to new WHMCS install +3. Reconfigure addon: Integration ID, API Secret (unchanged on GameCloud side) +4. Run reconciliation dry-run: **Addons → HexaHost GameCloud → Reconciliation** +5. Re-register mTLS fingerprint if client certificate changes + +See [WHMCS installation](../integrations/whmcs/installation.md). + +## MinIO / S3 migration + +```bash +mc mirror old/gamecloud new/gamecloud +``` + +Update `S3_ENDPOINT` and credentials in `.env.prod`, restart API and worker. + +## Rollback + +Keep old host powered but isolated for 72 hours. Rollback = revert DNS + restart old compose stack from pre-migration backup. + +## Related + +- [Backup and restore](backup-restore.md) +- [Disaster recovery](disaster-recovery.md) +- [Node drain](node-drain.md) diff --git a/docs/operations/traefik.md b/docs/operations/traefik.md new file mode 100644 index 0000000..369f4bd --- /dev/null +++ b/docs/operations/traefik.md @@ -0,0 +1,96 @@ +# Traefik operations + +HexaHost GameCloud exposes the customer panel (`web`) and REST API (`api`) through Traefik on the control plane host. Game nodes and the edge gateway are **not** routed through this stack. + +## Layout + +| Path | Purpose | +|------|---------| +| `deploy/traefik/traefik.yml` | Static config — entry points, ACME, providers | +| `deploy/traefik/dynamic/gamecloud.yml` | Middleware, TLS options, optional file routers | +| `deploy/compose/compose.prod.yml` | Docker labels for primary routing | + +## Prerequisites + +```bash +docker network create traefik-network +``` + +Set in `.env.prod`: + +```env +TRAEFIK_NETWORK=traefik-network +WEB_HOST=panel.example.net +API_HOST=api.example.net +ACME_EMAIL=ops@example.net +APP_URL=https://panel.example.net +API_URL=https://api.example.net +TRUSTED_PROXY_COUNT=1 +``` + +## Run Traefik + +Example standalone container (adjust paths): + +```bash +docker run -d \ + --name traefik \ + --restart always \ + -p 80:80 -p 443:443 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /opt/hexahost-gamecloud/deploy/traefik/traefik.yml:/etc/traefik/traefik.yml:ro \ + -v /opt/hexahost-gamecloud/deploy/traefik/dynamic:/etc/traefik/dynamic:ro \ + -v /opt/hexahost-gamecloud/letsencrypt:/letsencrypt \ + --network traefik-network \ + traefik:v3.2 +``` + +Then start the application stack: + +```bash +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d +``` + +## Routing + +| Host | Service | Health check | +|------|---------|--------------| +| `panel.example.net` | `web:3000` | `GET /de` | +| `api.example.net` | `api:3001` | `GET /api/v1/health/live` | + +HTTP on port 80 redirects to HTTPS. API requests receive rate limiting (`100 req/s` average, burst `200`). + +## WHMCS integration mTLS + +WHMCS integration traffic uses the same `api.example.net` host. When `INTEGRATION_MTLS_ENABLED=true`, terminate client certificates at Traefik or nginx and forward `X-HGC-Client-Cert-Fingerprint`. See [WHMCS mTLS](../integrations/whmcs/mtls.md) and `deploy/nginx/integration-mtls.conf.example`. + +## Certificate renewal + +Let's Encrypt certificates are stored in `/letsencrypt/acme.json`. Back up this file before host migrations. Traefik renews automatically; monitor expiry via the Traefik dashboard or Prometheus metrics. + +## Troubleshooting + +### 502 Bad Gateway + +1. Confirm `api` / `web` containers are healthy: `docker compose ps` +2. Verify both services attach to `traefik-network` +3. Check label `traefik.docker.network` matches the external network name + +### Certificate errors + +1. Ensure ports 80 and 443 are reachable from the internet for HTTP-01 challenge +2. Confirm `ACME_EMAIL` is valid +3. Inspect Traefik logs: `docker logs traefik 2>&1 | jq .` + +### Wrong client IP in audit logs + +Increase `TRUSTED_PROXY_COUNT` on the API to match the number of reverse-proxy hops. + +## Ansible + +Control plane provisioning is described in `deploy/ansible/control-plane.yml`. After OS setup, copy Traefik and compose files to `/opt/hexahost-gamecloud` and enable systemd or compose restart policies. + +## Related + +- [Control plane operations](control-plane.md) +- [Installation (development)](installation.md) diff --git a/docs/operations/upgrades.md b/docs/operations/upgrades.md new file mode 100644 index 0000000..b0062c4 --- /dev/null +++ b/docs/operations/upgrades.md @@ -0,0 +1,86 @@ +# Control plane upgrades + +Rolling upgrades for HexaHost GameCloud API, worker, web, and infrastructure containers on the control plane. + +## Version pinning + +Set in `.env.prod`: + +```env +GAMECLOUD_IMAGE_TAG=1.2.0 +``` + +Build and tag images in CI, or pull from your registry. Never deploy `:latest` in production without a rollback plan. + +## Pre-upgrade checklist + +- [ ] Read release notes and migration requirements (`docs/IMPLEMENTATION_STATUS.md`) +- [ ] Fresh PostgreSQL backup ([backup-restore](backup-restore.md)) +- [ ] WHMCS addon/server module compatibility verified +- [ ] Maintenance window communicated if API downtime expected +- [ ] Staging deploy completed successfully + +## Standard rolling upgrade + +```bash +cd /opt/hexahost-gamecloud + +# Pull/build new images +export GAMECLOUD_IMAGE_TAG=1.2.0 +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod pull +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod build + +# Run database migrations (if applicable — via API container one-shot) +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod run --rm api \ + pnpm --filter @hexahost/api prisma migrate deploy + +# Recreate app containers one at a time +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d --no-deps worker +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d --no-deps api +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d --no-deps web +``` + +## Infrastructure upgrades + +| Service | Notes | +|---------|-------| +| PostgreSQL | Major version requires `pg_upgrade` or dump/restore — plan maintenance window | +| Redis | Minor upgrades usually safe; snapshot before upgrade | +| MinIO | Follow MinIO release notes; backup volume first | +| Traefik | Test dynamic config with `traefik validate` equivalent (dry-run container) | + +## Zero-downtime considerations + +- Run at least two API instances behind Traefik for true zero-downtime (single-node compose has brief API restart) +- Worker: only one active consumer per-correct for some jobs; use graceful shutdown (SIGTERM) before replace +- Web: Next.js standalone reload causes short 502; upgrade during low traffic + +## Rollback + +```bash +export GAMECLOUD_IMAGE_TAG=1.1.0 +docker compose -f deploy/compose/compose.prod.yml --env-file .env.prod up -d api worker web +``` + +If migrations ran forward-only, restore PostgreSQL from pre-upgrade dump. + +## WHMCS module upgrades + +See [WHMCS upgrades](../integrations/whmcs/upgrades.md). Upgrade GameCloud API before WHMCS server module when release notes specify API-first ordering. + +## Post-upgrade verification + +```bash +curl -sf https://api.example.net/api/v1/health/live +curl -sf https://panel.example.net/de +``` + +- Admin panel login +- WHMCS test suspend/unsuspend on staging service +- Worker queue depth normal +- Node heartbeats within 30s + +## Related + +- [Control plane operations](control-plane.md) +- [Node drain](node-drain.md) — upgrade game nodes separately diff --git a/docs/security/backup-encryption.md b/docs/security/backup-encryption.md new file mode 100644 index 0000000..658bfc7 --- /dev/null +++ b/docs/security/backup-encryption.md @@ -0,0 +1,9 @@ +# Backup Encryption at Rest + +GameCloud backups are stored in S3-compatible object storage. Production deployments should enable: + +1. **Bucket SSE** — Server-side encryption (AES-256) on the backup bucket +2. **Application envelope encryption** (roadmap) — Per-backup data keys wrapped with `ENCRYPTION_KEY` +3. **Key rotation** — Documented in `docs/security/secrets.md` + +Current MVP stores backups without client-side encryption. Enable bucket policies before production. diff --git a/docs/security/container-hardening.md b/docs/security/container-hardening.md new file mode 100644 index 0000000..b429612 --- /dev/null +++ b/docs/security/container-hardening.md @@ -0,0 +1,14 @@ +# Container Hardening + +The node-agent applies baseline Docker hardening: + +- `cap_drop: ALL` with minimal adds +- `security_opt: no-new-privileges` +- PID limits and log rotation + +## Roadmap (Post-MVP) + +- **Seccomp** — Custom profile per software family under `deploy/seccomp/` +- **AppArmor** — Node-level profiles for game server containers + +See `apps/node-agent/internal/docker/client.go` for current defaults. diff --git a/docs/security/container-isolation.md b/docs/security/container-isolation.md new file mode 100644 index 0000000..0f52cf0 --- /dev/null +++ b/docs/security/container-isolation.md @@ -0,0 +1,71 @@ +# Container isolation + +How HexaHost GameCloud isolates Minecraft server workloads on game nodes. + +## Model + +Each customer server runs as a **non-privileged Docker container** on a dedicated game node. The node-agent creates and manages containers; customers never receive Docker socket access. + +``` +Control plane (trusted) ──mTLS──► Node agent (trusted) ──► Game container (untrusted) +``` + +## Container hardening (target profile) + +| Control | Setting | +|---------|---------| +| User | Non-root UID inside container | +| Privileges | `--privileged=false` | +| Capabilities | Drop ALL, add only required (none for vanilla Java) | +| seccomp | Docker default or custom profile (Phase 2) | +| AppArmor | Docker default profile | +| Network | Bridge network per server; no `host` network mode | +| Read-only root | Where compatible with selected software family | +| Memory / CPU | cgroup limits from plan slug | +| PIDs limit | Prevents fork bombs | + +## Network isolation + +- Game containers reach the internet for Minecraft auth and mod downloads only through controlled egress +- No route to control plane PostgreSQL, Redis, or internal APIs +- Node-agent management port (`9100`) bound to localhost or management VLAN only + +See threat T05 and T12 in [threat model](threat-model.md). + +## Storage isolation + +- Per-server data directory on node disk: `/var/lib/hgc-node/servers/{serverId}/` +- Path traversal prevented server-side; symlinks rejected on archive extract +- Backups uploaded to object storage — not readable by other tenants + +## Image policy + +- Only catalog-approved runtime images (digest-pinned) +- Customers cannot push custom images +- Periodic image vulnerability scan in CI (Phase 9) + +## Multi-tenant on one node + +The scheduler enforces RAM and server count limits per node. There is no shared filesystem between server containers except the read-only image layers. + +## Operator responsibilities + +| Task | Frequency | +|------|-----------| +| Docker security updates on nodes | Monthly | +| Review AppArmor/seccomp profiles after Minecraft/Java major bumps | Per release | +| Audit `docker ps` for unexpected privileged containers | Weekly | + +Bootstrap hardening via `deploy/ansible/game-node.yml`. + +## Verification + +- Attempt cross-container network scan from test server (should fail to reach agent port) +- Confirm `docker inspect` shows `Privileged: false` +- Penetration test before production go-live + +## Related + +- [Threat model](threat-model.md) +- [Game node operations](../operations/game-node.md) +- [Secrets](secrets.md) diff --git a/docs/security/data-retention.md b/docs/security/data-retention.md new file mode 100644 index 0000000..bcb6664 --- /dev/null +++ b/docs/security/data-retention.md @@ -0,0 +1,66 @@ +# Data retention + +Retention periods and deletion procedures for HexaHost GameCloud customer and operational data. + +## Policy summary + +| Data type | Retention | Deletion trigger | +|-----------|-----------|------------------| +| Account profile | Life of account + 90 days | Account deletion request | +| Game server world (live) | Life of service | Terminate in WHMCS / admin | +| Automated backups | Per plan (7–30 days default) | Lifecycle job + S3 expiry | +| Manual backup downloads | Link valid 24 h; object 7 days | Worker cleanup | +| Audit log | 24 months | Scheduled purge job | +| Application logs (Loki) | 30 days (`720h` in loki-config) | Loki compactor | +| Session tokens | 7 days idle / 30 days max | Redis TTL | +| WHMCS sync events | 90 days acknowledged | Addon retention SQL | +| DNS record history | 12 months | DB maintenance | +| Metering / usage samples | 13 months (billing) | Aggregated then purged | + +Adjust periods in contracts and WHMCS product terms. Document customer-facing retention in your privacy policy (German: *Datenschutzerklärung*). + +## Backup retention + +Backup objects live under `s3://{bucket}/backups/{serverId}/`. The worker applies plan-based retention: + +- Free/starter plans: 7 daily slots +- Pro plans: 14 daily + 4 weekly +- Enterprise: configurable via admin + +Expired backups are deleted from object storage; metadata rows in PostgreSQL are soft-deleted then purged after 7 days. + +## Termination flow + +When WHMCS calls `TerminateAccount`: + +1. Server stopped on node +2. Final backup optional (plan-dependent) +3. World data on node deleted within 24 h +4. S3 prefix `backups/{serverId}/` deleted within 7 days +5. PostgreSQL server row anonymised or hard-deleted per legal requirement + +## GDPR / DSGVO considerations + +- **Right to erasure:** Export then delete user row; cascade removes owned servers +- **Data portability:** World export ZIP + backup download via panel +- **Processor agreement:** Required between you (controller) and HexaHost GameCloud operator if different entity + +Logs may contain IP addresses and user IDs — treat as personal data where applicable. + +## Operational data + +| Store | Purge method | +|-------|--------------| +| PostgreSQL audit | `DELETE FROM audit_events WHERE created_at < NOW() - INTERVAL '24 months'` (automated job) | +| Redis | TTL-based; no long-term PII | +| MinIO access logs | Provider lifecycle rule | + +## Legal hold + +Suspend automated deletion for accounts under litigation hold. Flag in admin metadata; worker skips purge for affected `serverId`. + +## Related + +- [Backup and restore](../operations/backup-restore.md) +- [Object storage](../operations/object-storage.md) +- [Threat model](threat-model.md) — T16 backup integrity diff --git a/docs/security/secrets.md b/docs/security/secrets.md new file mode 100644 index 0000000..cba9385 --- /dev/null +++ b/docs/security/secrets.md @@ -0,0 +1,95 @@ +# Secrets management + +Guidelines for generating, storing, and rotating secrets in HexaHost GameCloud deployments. + +## Classification + +| Secret | Location | Rotation | +|--------|----------|----------| +| `SESSION_SECRET` | `.env.prod` on control plane | Quarterly; invalidates sessions | +| `ENCRYPTION_KEY` | `.env.prod` | Annually; requires re-encryption plan | +| `POSTGRES_PASSWORD` | `.env.prod`, compose | Annually | +| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `.env.prod`, MinIO IAM | Quarterly | +| `WHMCS_API_SECRET` | GameCloud `.env.prod` + WHMCS addon | On compromise; per installation | +| `WHMCS_WEBHOOK_SECRET` | Both sides | On compromise | +| `NODE_TOKEN` | Per-node enrollment | One-time at enroll; rotate on rebuild | +| Node mTLS keys | `/etc/hgc-node/` on game nodes | Before cert expiry | +| `EDGE_INTERNAL_API_KEY` | Edge gateway + API | Quarterly | +| `RFC2136_KEY_SECRET` | API/worker env | Annually | +| `STRIPE_*` | API env (if used) | Per Stripe dashboard policy | +| Traefik `acme.json` | `/letsencrypt/` | Auto-renewed; backup only | + +## Generation + +```bash +# 32-byte secrets (SESSION_SECRET, ENCRYPTION_KEY, API secrets) +openssl rand -base64 32 + +# WHMCS integration — minimum 32 characters +openssl rand -hex 24 +``` + +Never use development defaults from `.env.example` in production. + +## Storage rules + +1. **Never commit** `.env`, `.env.prod`, or key material to git +2. Restrict file permissions: `chmod 600 .env.prod`, owner `gamecloud` +3. Prefer secret manager (HashiCorp Vault, SOPS, cloud provider SM) over plain files for multi-host +4. Ansible: use `ansible-vault` for inventory secrets referenced in `deploy/ansible/` + +## Distribution + +| From | To | Channel | +|------|-----|---------| +| Operator | Control plane | SSH + encrypted archive | +| Control plane | WHMCS admin | Out-of-band (password manager share) | +| Control plane | Game node | Enrollment token via secure ticket | + +WHMCS **Integration ID** is not secret but must match exactly on both sides. + +## Rotation procedures + +### SESSION_SECRET + +1. Generate new value +2. Update `.env.prod`, restart `api` and `web` +3. All users must log in again + +### WHMCS_API_SECRET + +1. Update GameCloud `.env.prod` and restart API +2. Update WHMCS addon **API Secret** immediately after — expect brief auth failures +3. No WHMCS module reinstall required + +### Database password + +1. `ALTER USER gamecloud PASSWORD '...'` in PostgreSQL +2. Update `DATABASE_URL` / `POSTGRES_PASSWORD` in `.env.prod` +3. Restart `api`, `worker` + +### Node token compromise + +1. Revoke token in admin API for affected `NODE_ID` +2. Issue new enrollment token +3. Reinstall node-agent with new token and fresh mTLS cert + +## Logging and redaction + +- Structured logs must not print env dumps or Authorization headers +- WHMCS module calls redact `password`, `secret`, `apiSecret` in module logs +- OpenTelemetry spans must not include query strings with tokens + +## Development vs production + +| Variable | Development | Production | +|----------|-------------|------------| +| `INTEGRATION_MTLS_ENABLED` | `false` | `true` (recommended) | +| `NODE_TLS_SKIP_VERIFY` | `true` | `false` | +| Default MinIO credentials | Allowed | **Forbidden** | + +## Related + +- [Threat model](threat-model.md) +- [WHMCS security](../integrations/whmcs/security.md) +- [Data retention](data-retention.md) diff --git a/integrations/whmcs/composer.json b/integrations/whmcs/composer.json new file mode 100644 index 0000000..82b378d --- /dev/null +++ b/integrations/whmcs/composer.json @@ -0,0 +1,17 @@ +{ + "name": "hexahost/whmcs-integration-dev", + "description": "Development dependencies for HexaHost GameCloud WHMCS integration tests", + "type": "project", + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "autoload-dev": { + "classmap": [ + "modules/servers/hexagamecloud/lib/" + ] + }, + "scripts": { + "test": "phpunit", + "test:unit": "phpunit --testsuite Unit" + } +} diff --git a/integrations/whmcs/modules/servers/hexagamecloud/README.md b/integrations/whmcs/modules/servers/hexagamecloud/README.md index aaf1670..4937ab1 100644 --- a/integrations/whmcs/modules/servers/hexagamecloud/README.md +++ b/integrations/whmcs/modules/servers/hexagamecloud/README.md @@ -1,7 +1,31 @@ -# Server provisioning module placeholder +# HexaHost GameCloud WHMCS Server Module -Implementation planned in WHMCS Phase A–B. +Provisioning module for WHMCS that integrates with the GameCloud Integration API. -Target install path: `/modules/servers/hexagamecloud/` +## Features -See `integrations/whmcs/README.md` for the full module layout. +- Provision, suspend, unsuspend, terminate, renew, change package +- Client-area SSO to the GameCloud panel +- Custom actions: **Start**, **Stop**, **Restart**, **Backup** (Integration API `actions/*`) +- Admin service tab with live reconcile snapshot +- DE/EN language files + +## Configuration + +| Field | Description | +|-------|-------------| +| Hostname | GameCloud API base URL (e.g. `https://api.example.net`) | +| Username | WHMCS integration ID | +| Password | Integration API secret | + +## Build package + +```bash +./build-package.sh +``` + +## Tests + +```bash +composer test +``` diff --git a/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php b/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php index 1751e2f..8130358 100644 --- a/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php +++ b/integrations/whmcs/modules/servers/hexagamecloud/hexagamecloud.php @@ -1,12 +1,18 @@ hexagamecloud_resolveRenewInvoiceId($params), + 'periodEnd' => hexagamecloud_formatIso8601($params['nextduedate'] ?? null), + ], static fn ($value) => $value !== null && $value !== ''); + + $client->renewService( + $serviceId, + $payload, + HexaGameCloudIdempotency::renewKey($serviceId, $params) + ); + + return 'success'; + } catch (Throwable $exception) { + logModuleCall( + 'hexagamecloud', + __FUNCTION__, + $params, + $exception->getMessage(), + $exception->getMessage(), + ['password', 'secret', 'apiSecret'] + ); + return $exception->getMessage(); + } +} + function hexagamecloud_ChangePackage(array $params): string { try { @@ -121,6 +157,137 @@ function hexagamecloud_AdminSingleSignOn(array $params) return hexagamecloud_requestSso($params, 'admin'); } +function hexagamecloud_ClientArea(array $params): array +{ + hexagamecloud_loadLanguage($params); + + $serviceId = (string) $params['serviceid']; + $snapshot = hexagamecloud_fetchServiceSnapshot($params); + $server = $snapshot['service']['server'] ?? []; + $linkStatus = (string) ($snapshot['service']['status'] ?? 'PENDING'); + + return [ + 'templatefile' => 'templates/clientarea', + 'vars' => [ + 'LANG' => hexagamecloud_langVars(), + 'apiWarning' => $snapshot['warning'], + 'notProvisioned' => $linkStatus === 'PENDING' && ($server['id'] ?? '') === '', + 'serverName' => (string) ($server['name'] ?? ($params['domain'] ?? '')), + 'technicalStatus' => (string) ($server['status'] ?? 'UNKNOWN'), + 'joinAddress' => (string) ($server['joinHostname'] ?? '-'), + 'edition' => (string) ($server['edition'] ?? 'JAVA'), + 'softwareFamily' => (string) ($server['softwareFamily'] ?? HexaGameCloudProductMapping::resolveSoftwareFamily($params)), + 'minecraftVersion' => (string) ($server['minecraftVersion'] ?? HexaGameCloudProductMapping::resolveMinecraftVersion($params)), + 'planSlug' => HexaGameCloudProductMapping::resolvePlanSlug($params), + 'ramMb' => (int) ($server['ramMb'] ?? 0), + 'nextDueDate' => (string) ($params['nextduedate'] ?? '-'), + 'billingStatus' => $linkStatus === 'SUSPENDED' + ? hexagamecloud_lang('hexagamecloud_suspended_yes') + : hexagamecloud_lang('hexagamecloud_suspended_no'), + 'lastSync' => $snapshot['syncedAt'], + ], + ]; +} + +function hexagamecloud_ClientAreaCustomButtonArray(): array +{ + return [ + hexagamecloud_lang('hexagamecloud_manage_panel') => 'ManagePanel', + hexagamecloud_lang('hexagamecloud_action_start') => 'StartServer', + hexagamecloud_lang('hexagamecloud_action_stop') => 'StopServer', + hexagamecloud_lang('hexagamecloud_action_restart') => 'RestartServer', + hexagamecloud_lang('hexagamecloud_action_backup') => 'BackupServer', + ]; +} + +function hexagamecloud_StartServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'start'); +} + +function hexagamecloud_StopServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'stop'); +} + +function hexagamecloud_RestartServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'restart'); +} + +function hexagamecloud_BackupServer(array $params): string +{ + return hexagamecloud_serviceAction($params, 'backup'); +} + +function hexagamecloud_serviceAction(array $params, string $action): string +{ + try { + $client = new HexaGameCloudApiClient($params); + $serviceId = (string) $params['serviceid']; + if ($action === 'start') { + $client->startService($serviceId); + } elseif ($action === 'stop') { + $client->stopService($serviceId); + } elseif ($action === 'restart') { + $client->restartService($serviceId); + } else { + $client->backupService($serviceId); + } + return 'success'; + } catch (Throwable $exception) { + logModuleCall('hexagamecloud', $action, $params, $exception->getMessage()); + return $exception->getMessage(); + } +} + +function hexagamecloud_ManagePanel(array $params): array +{ + return hexagamecloud_requestSso($params, 'service'); +} + +function hexagamecloud_AdminServicesTabFields(array $params): array +{ + hexagamecloud_loadLanguage($params); + + $serviceId = (string) $params['serviceid']; + $snapshot = hexagamecloud_fetchServiceSnapshot($params); + $service = $snapshot['service']; + $server = $service['server'] ?? []; + $metadata = HexaGameCloudServiceMetadata::load($serviceId); + $warning = $snapshot['warning'] !== null + ? '
    ' . htmlspecialchars($snapshot['warning'], ENT_QUOTES, 'UTF-8') . '
    ' + : ''; + + return [ + hexagamecloud_lang('hexagamecloud_admin_server_id') => $warning . htmlspecialchars((string) ($server['id'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_user_id') => htmlspecialchars((string) ($server['userId'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_link_status') => htmlspecialchars((string) ($service['status'] ?? 'PENDING'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_technical_status') => htmlspecialchars((string) ($server['status'] ?? 'UNKNOWN'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_node') => htmlspecialchars((string) ($server['nodeId'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_host_port') => htmlspecialchars((string) ($server['hostPort'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_plan') => htmlspecialchars(HexaGameCloudProductMapping::resolvePlanSlug($params), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_ram') => htmlspecialchars((string) ((int) ($server['ramMb'] ?? 0)) . ' MiB', ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_last_sync') => htmlspecialchars((string) ($snapshot['syncedAt'] ?? '-'), ENT_QUOTES, 'UTF-8'), + hexagamecloud_lang('hexagamecloud_admin_correlation_note') => '', + ]; +} + +function hexagamecloud_AdminServicesTabFieldsSave(array $params): void +{ + $serviceId = (string) $params['serviceid']; + $note = trim((string) ($params['hexagamecloud_correlation_note'] ?? '')); + $existing = HexaGameCloudServiceMetadata::load($serviceId); + + HexaGameCloudServiceMetadata::save($serviceId, array_merge($existing, [ + 'correlation_note' => $note, + 'updated_at' => gmdate('c'), + 'updated_by_admin_id' => (string) ($params['adminid'] ?? ''), + ])); +} + function hexagamecloud_TestConnection(array $params): array { try { @@ -233,6 +400,103 @@ function hexagamecloud_lifecycleAction(array $params, string $action, array $bod } } +function hexagamecloud_fetchServiceSnapshot(array $params): array +{ + $serviceId = (string) $params['serviceid']; + $cached = HexaGameCloudServiceMetadata::load($serviceId); + $snapshot = [ + 'service' => $cached['service'] ?? [ + 'externalServiceId' => $serviceId, + 'status' => 'PENDING', + 'server' => [], + ], + 'warning' => null, + 'syncedAt' => $cached['synced_at'] ?? null, + ]; + + try { + $client = new HexaGameCloudApiClient($params); + $service = $client->getService($serviceId); + $snapshot['service'] = $service; + $snapshot['syncedAt'] = gmdate('c'); + + HexaGameCloudServiceMetadata::save($serviceId, [ + 'service' => $service, + 'synced_at' => $snapshot['syncedAt'], + 'correlation_note' => $cached['correlation_note'] ?? '', + 'updated_by_admin_id' => $cached['updated_by_admin_id'] ?? '', + ]); + } catch (Throwable $exception) { + logModuleCall('hexagamecloud', 'fetchServiceSnapshot', $params, $exception->getMessage()); + $snapshot['warning'] = hexagamecloud_lang('hexagamecloud_api_unavailable'); + } + + return $snapshot; +} + +function hexagamecloud_resolveRenewInvoiceId(array $params): ?string +{ + $invoiceId = (string) ($params['invoiceid'] ?? ''); + if ($invoiceId !== '') { + return $invoiceId; + } + + if (!empty($params['model']['invoiceid'])) { + return (string) $params['model']['invoiceid']; + } + + return null; +} + +function hexagamecloud_formatIso8601(?string $value): ?string +{ + if ($value === null || trim($value) === '') { + return null; + } + + $timestamp = strtotime($value); + if ($timestamp === false) { + return null; + } + + return gmdate('c', $timestamp); +} + +function hexagamecloud_loadLanguage(array $params): void +{ + static $loaded = false; + if ($loaded) { + return; + } + + $language = strtolower((string) ($params['clientsdetails']['language'] ?? $params['language'] ?? 'english')); + if (!in_array($language, ['english', 'german'], true)) { + $language = 'english'; + } + + $langFile = __DIR__ . '/lang/' . $language . '.php'; + if (!is_readable($langFile)) { + $langFile = __DIR__ . '/lang/english.php'; + } + + require $langFile; + $loaded = true; +} + +function hexagamecloud_lang(string $key): string +{ + global $_LANG; + + return (string) ($_LANG[$key] ?? $key); +} + +function hexagamecloud_langVars(): array +{ + global $_LANG; + + return is_array($_LANG ?? null) ? $_LANG : []; +} + function hexagamecloud_requestSso(array $params, string $kind) { try { diff --git a/integrations/whmcs/modules/servers/hexagamecloud/hooks.php b/integrations/whmcs/modules/servers/hexagamecloud/hooks.php new file mode 100644 index 0000000..18d410e --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/hooks.php @@ -0,0 +1,68 @@ + HEXAGAMECLOUD_SERVER_VERSION, + ]; +}); + +add_hook('AdminAreaHeadOutput', 1, function (array $vars): string { + if (($vars['filename'] ?? '') !== 'clientsservices') { + return ''; + } + + return ''; +}); + +add_hook('ServiceEdit', 1, function (array $vars): void { + if (($vars['moduletype'] ?? '') !== 'hexagamecloud') { + return; + } + + $serviceId = (string) ($vars['serviceid'] ?? ''); + if ($serviceId === '') { + return; + } + + logActivity('HexaGameCloud service edited in WHMCS admin (service #' . $serviceId . ')'); +}); diff --git a/integrations/whmcs/modules/servers/hexagamecloud/lang/english.php b/integrations/whmcs/modules/servers/hexagamecloud/lang/english.php new file mode 100644 index 0000000..042c411 --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/lang/english.php @@ -0,0 +1,35 @@ +request( + 'GET', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) + ); + } + + public function renewService(string $externalServiceId, array $payload, string $idempotencyKey): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/renew', + $payload, + $idempotencyKey + ); + } + public function changePackage(string $externalServiceId, array $payload): array { return $this->request( @@ -173,6 +191,46 @@ final class HexaGameCloudApiClient ); } + public function startService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/start', + [], + 'service:start:' . $externalServiceId + ); + } + + public function stopService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/stop', + [], + 'service:stop:' . $externalServiceId + ); + } + + public function restartService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/restart', + [], + 'service:restart:' . $externalServiceId + ); + } + + public function backupService(string $externalServiceId): array + { + return $this->request( + 'POST', + '/api/v1/integrations/whmcs/services/' . rawurlencode($externalServiceId) . '/actions/backup', + [], + 'service:backup:' . $externalServiceId + ); + } + private function request(string $method, string $pathWithQuery, array $body = [], ?string $idempotencySuffix = null): array { $pathParts = explode('?', $pathWithQuery, 2); diff --git a/integrations/whmcs/modules/servers/hexagamecloud/lib/Idempotency.php b/integrations/whmcs/modules/servers/hexagamecloud/lib/Idempotency.php new file mode 100644 index 0000000..55c9e23 --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/lib/Idempotency.php @@ -0,0 +1,26 @@ +where('operation', self::OPERATION_PREFIX . $serviceId) + ->orderBy('id', 'desc') + ->first(); + + if ($row === null || empty($row->metadata)) { + return []; + } + + $decoded = json_decode((string) $row->metadata, true); + + return is_array($decoded) ? $decoded : []; + } catch (Throwable) { + return []; + } + } + + public static function save(string $serviceId, array $data): void + { + $payload = json_encode($data, JSON_THROW_ON_ERROR); + + try { + $existing = Capsule::table('mod_hexagamecloud_operations') + ->where('operation', self::OPERATION_PREFIX . $serviceId) + ->orderBy('id', 'desc') + ->first(); + + if ($existing !== null) { + Capsule::table('mod_hexagamecloud_operations') + ->where('id', $existing->id) + ->update([ + 'metadata' => $payload, + 'status' => 'saved', + ]); + return; + } + + Capsule::table('mod_hexagamecloud_operations')->insert([ + 'operation' => self::OPERATION_PREFIX . $serviceId, + 'status' => 'saved', + 'metadata' => $payload, + 'created_at' => gmdate('Y-m-d H:i:s'), + ]); + } catch (Throwable $exception) { + logActivity('HexaGameCloud admin metadata save failed: ' . $exception->getMessage()); + } + } +} diff --git a/integrations/whmcs/modules/servers/hexagamecloud/templates/clientarea.tpl b/integrations/whmcs/modules/servers/hexagamecloud/templates/clientarea.tpl new file mode 100644 index 0000000..2bdf1a3 --- /dev/null +++ b/integrations/whmcs/modules/servers/hexagamecloud/templates/clientarea.tpl @@ -0,0 +1,39 @@ +
    +
    +

    {$LANG.hexagamecloud_server_overview}

    +
    +
    + {if $apiWarning} +
    {$apiWarning}
    + {/if} + {if $notProvisioned} +

    {$LANG.hexagamecloud_not_provisioned}

    + {else} +
    +
    {$LANG.hexagamecloud_server_name}
    +
    {$serverName}
    +
    {$LANG.hexagamecloud_status}
    +
    {$technicalStatus}
    +
    {$LANG.hexagamecloud_join_address}
    +
    {$joinAddress}
    +
    {$LANG.hexagamecloud_edition}
    +
    {$edition}
    +
    {$LANG.hexagamecloud_software}
    +
    {$softwareFamily}
    +
    {$LANG.hexagamecloud_version}
    +
    {$minecraftVersion}
    +
    {$LANG.hexagamecloud_plan}
    +
    {$planSlug}
    +
    {$LANG.hexagamecloud_ram}
    +
    {$ramMb} MiB
    +
    {$LANG.hexagamecloud_next_due}
    +
    {$nextDueDate}
    +
    {$LANG.hexagamecloud_suspend_status}
    +
    {$billingStatus}
    +
    + {if $lastSync} +

    {$LANG.hexagamecloud_last_sync}: {$lastSync}

    + {/if} + {/if} +
    +
    diff --git a/integrations/whmcs/packaging/build-package.sh b/integrations/whmcs/packaging/build-package.sh new file mode 100755 index 0000000..6182642 --- /dev/null +++ b/integrations/whmcs/packaging/build-package.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PACKAGING="${ROOT}/packaging" +MANIFEST="${PACKAGING}/manifest.json" +DIST="${ROOT}/dist" +STAGING="${DIST}/staging" +VERSION="$(python3 -c "import json; print(json.load(open('${MANIFEST}'))['version'])")" +PACKAGE_NAME="hexagamecloud-whmcs-${VERSION}" +ZIP_PATH="${DIST}/${PACKAGE_NAME}.zip" +CHECKSUMS_PATH="${DIST}/checksums.txt" + +rm -rf "${STAGING}" "${ZIP_PATH}" +mkdir -p "${STAGING}/modules/servers" "${STAGING}/modules/addons" "${STAGING}/hooks" "${STAGING}/lib" "${DIST}" + +copy_tree() { + local source="$1" + local target="$2" + if command -v rsync >/dev/null 2>&1; then + rsync -a --exclude '.gitkeep' "${source}/" "${target}/" + else + cp -a "${source}/." "${target}/" + fi +} + +copy_tree "${ROOT}/modules/servers/hexagamecloud" "${STAGING}/modules/servers/hexagamecloud" +copy_tree "${ROOT}/modules/addons/hexagamecloud" "${STAGING}/modules/addons/hexagamecloud" +copy_tree "${ROOT}/hooks" "${STAGING}/hooks" +copy_tree "${ROOT}/lib" "${STAGING}/lib" + +cp "${MANIFEST}" "${STAGING}/manifest.json" + +( + cd "${STAGING}" + zip -r "${ZIP_PATH}" . +) + +SHA256="$(sha256sum "${ZIP_PATH}" | awk '{print $1}')" +SHA512="$(sha512sum "${ZIP_PATH}" | awk '{print $1}')" +FILE_SIZE="$(stat -c '%s' "${ZIP_PATH}")" +BUILT_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + +cat > "${CHECKSUMS_PATH}" < + + + + tests/Unit + + + tests/Contract + + + diff --git a/integrations/whmcs/tests/Contract/README.md b/integrations/whmcs/tests/Contract/README.md new file mode 100644 index 0000000..fe15139 --- /dev/null +++ b/integrations/whmcs/tests/Contract/README.md @@ -0,0 +1,30 @@ +# Contract tests + +Contract tests validate WHMCS module requests and responses against the GameCloud integration API (`/api/v1/integrations/whmcs/*`). + +## Scope + +- Request signing headers (`X-HGC-Integration-ID`, `X-HGC-Signature`, `X-HGC-Idempotency-Key`, …) +- Canonical HMAC signature base string +- JSON request/response shapes from `packages/contracts/src/whmcs.ts` +- Idempotent lifecycle operations (`provision`, `suspend`, `renew`, `change-package`) + +## Running + +Contract tests require a reachable GameCloud API or a recorded mock server. They are not executed in CI by default. + +```bash +# From integrations/whmcs after installing dev dependencies: +composer install +./vendor/bin/phpunit --testsuite Contract +``` + +## Fixtures + +Sample WHMCS `$params` payloads and signed API examples will live under `tests/Fixtures/` as the contract suite grows. + +## Related + +- OpenAPI / Zod contracts: `packages/contracts/src/whmcs.ts` +- Server module client: `modules/servers/hexagamecloud/lib/ApiClient.php` +- Addon module client: `modules/addons/hexagamecloud/lib/ApiClient.php` diff --git a/integrations/whmcs/tests/Unit/IdempotencyTest.php b/integrations/whmcs/tests/Unit/IdempotencyTest.php new file mode 100644 index 0000000..ffa6381 --- /dev/null +++ b/integrations/whmcs/tests/Unit/IdempotencyTest.php @@ -0,0 +1,53 @@ +assertSame('service:renew:12345:invoice-99', $key); + } + + public function testServiceOperationKeyFallsBackToDateSuffix(): void + { + $key = HexaGameCloudIdempotency::serviceOperationKey('suspend', '42', null); + + $this->assertStringStartsWith('service:suspend:42:', $key); + $this->assertMatchesRegularExpression('/^service:suspend:42:\d{4}-\d{2}-\d{2}$/', $key); + } + + public function testRenewKeyPrefersInvoiceId(): void + { + $key = HexaGameCloudIdempotency::renewKey('9001', [ + 'invoiceid' => '7788', + 'renewalid' => '5555', + ]); + + $this->assertSame('service:renew:9001:7788', $key); + } + + public function testRenewKeyUsesRenewalIdWhenInvoiceMissing(): void + { + $key = HexaGameCloudIdempotency::renewKey('9001', [ + 'renewalid' => '5555', + ]); + + $this->assertSame('service:renew:9001:5555', $key); + } + + public function testRenewKeyUsesModelInvoiceId(): void + { + $key = HexaGameCloudIdempotency::renewKey('9001', [ + 'model' => ['invoiceid' => '6611'], + ]); + + $this->assertSame('service:renew:9001:6611', $key); + } +} diff --git a/packages/catalog/src/curseforge.ts b/packages/catalog/src/curseforge.ts new file mode 100644 index 0000000..fbdaf2e --- /dev/null +++ b/packages/catalog/src/curseforge.ts @@ -0,0 +1,74 @@ +export interface CurseForgeProject { + id: number; + name: string; + slug: string; + summary: string; + downloadCount: number; + logo?: { thumbnailUrl?: string }; +} + +export interface CurseForgeSearchResponse { + data: CurseForgeProject[]; + pagination: { totalCount: number }; +} + +export interface CurseForgeFile { + id: number; + displayName: string; + fileName: string; + downloadUrl: string; + fileLength: number; + gameVersions: string[]; + modLoader?: number; +} + +const CURSEFORGE_API = 'https://api.curseforge.com/v1'; + +function apiKey(): string { + const key = process.env['CURSEFORGE_API_KEY']; + if (!key) { + throw new Error('CURSEFORGE_API_KEY is not configured'); + } + return key; +} + +async function curseforgeFetch(path: string): Promise { + const response = await fetch(`${CURSEFORGE_API}${path}`, { + headers: { + 'x-api-key': apiKey(), + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`CurseForge API error: ${response.status}`); + } + + return (await response.json()) as T; +} + +export async function searchCurseForgeMods(input: { + gameId?: number; + searchFilter: string; + pageSize?: number; + classId?: number; +}): Promise { + const params = new URLSearchParams({ + gameId: String(input.gameId ?? 432), + searchFilter: input.searchFilter, + pageSize: String(input.pageSize ?? 20), + }); + if (input.classId !== undefined) { + params.set('classId', String(input.classId)); + } + + return curseforgeFetch(`/mods/search?${params.toString()}`); +} + +export async function getCurseForgeModFiles(modId: number): Promise<{ data: CurseForgeFile[] }> { + return curseforgeFetch<{ data: CurseForgeFile[] }>(`/mods/${modId}/files`); +} + +export async function getCurseForgeMod(modId: number): Promise<{ data: CurseForgeProject }> { + return curseforgeFetch<{ data: CurseForgeProject }>(`/mods/${modId}`); +} diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts index 7584d4b..9415cfd 100644 --- a/packages/catalog/src/index.ts +++ b/packages/catalog/src/index.ts @@ -11,6 +11,15 @@ export { type SoftwareFamily, } from './modrinth'; +export { + searchCurseForgeMods, + getCurseForgeModFiles, + getCurseForgeMod, + type CurseForgeProject, + type CurseForgeSearchResponse, + type CurseForgeFile, +} from './curseforge'; + export { resolveModrinthLoader, resolveProjectType, diff --git a/packages/contracts/src/abuse.ts b/packages/contracts/src/abuse.ts new file mode 100644 index 0000000..9067f3b --- /dev/null +++ b/packages/contracts/src/abuse.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +export const abuseReportStatusSchema = z.enum([ + 'OPEN', + 'INVESTIGATING', + 'RESOLVED', + 'DISMISSED', +]); + +export const createAbuseReportSchema = z.object({ + reason: z.string().min(1).max(256), + details: z.string().max(4096).optional(), + targetUserId: z.string().uuid().optional(), + serverId: z.string().uuid().optional(), +}); + +export const abuseReportSchema = z.object({ + id: z.string().uuid(), + reporterId: z.string().uuid().nullable(), + targetUserId: z.string().uuid().nullable(), + serverId: z.string().uuid().nullable(), + status: abuseReportStatusSchema, + reason: z.string(), + details: z.string().nullable(), + resolution: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); + +export const abuseReportListResponseSchema = z.object({ + reports: z.array(abuseReportSchema), +}); + +export const updateAbuseReportSchema = z.object({ + status: abuseReportStatusSchema.optional(), + resolution: z.string().max(4096).optional(), +}); + +export type CreateAbuseReport = z.infer; +export type AbuseReport = z.infer; +export type AbuseReportListResponse = z.infer; +export type UpdateAbuseReport = z.infer; diff --git a/packages/contracts/src/compliance.ts b/packages/contracts/src/compliance.ts new file mode 100644 index 0000000..ea0c52c --- /dev/null +++ b/packages/contracts/src/compliance.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const dataExportRequestSchema = z.object({ + id: z.string().uuid(), + status: z.enum(['PENDING', 'PROCESSING', 'READY', 'FAILED', 'EXPIRED']), + downloadUrl: z.string().url().nullable(), + expiresAt: z.string().datetime().nullable(), + completedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +export const accountDeletionRequestSchema = z.object({ + id: z.string().uuid(), + status: z.enum(['PENDING', 'SCHEDULED', 'COMPLETED', 'CANCELLED']), + scheduledFor: z.string().datetime(), + completedAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +export const requestAccountDeletionSchema = z.object({ + confirmEmail: z.string().email(), +}); + +export type DataExportRequest = z.infer; +export type AccountDeletionRequest = z.infer; diff --git a/packages/contracts/src/files.ts b/packages/contracts/src/files.ts index d007760..0f99c03 100644 --- a/packages/contracts/src/files.ts +++ b/packages/contracts/src/files.ts @@ -21,6 +21,7 @@ export const fileContentSchema = z.object({ path: z.string(), content: z.string(), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + version: z.number().int().optional(), }); export const readFileContentQuerySchema = z.object({ @@ -31,6 +32,27 @@ export const updateFileContentSchema = z.object({ path: z.string().min(1), content: z.string(), encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + version: z.number().int().optional(), +}); + +export const uploadFileSchema = z.object({ + path: z.string().min(1), + content: z.string(), + encoding: z.enum(['utf-8', 'base64']).default('base64'), +}); + +export const filePathSchema = z.object({ + path: z.string().min(1), +}); + +export const archiveFilesSchema = z.object({ + paths: z.array(z.string().min(1)).min(1).max(100), + archiveName: z.string().min(1).max(128).optional(), +}); + +export const unarchiveFileSchema = z.object({ + archivePath: z.string().min(1), + destinationPath: z.string().default('.'), }); export type ListFilesQuery = z.infer; @@ -39,3 +61,6 @@ export type FileListResponse = z.infer; export type FileContent = z.infer; export type ReadFileContentQuery = z.infer; export type UpdateFileContent = z.infer; +export type UploadFile = z.infer; +export type ArchiveFiles = z.infer; +export type UnarchiveFile = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 90bcacf..5b6448b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -61,6 +61,7 @@ export { serverEditionSchema, softwareFamilySchema, createServerRequestSchema, + updateServerRequestSchema, serverResponseSchema, serverListResponseSchema, serverActionResponseSchema, @@ -68,6 +69,7 @@ export { type ServerEdition, type SoftwareFamily, type CreateServerRequest, + type UpdateServerRequest, type ServerResponse, type ServerListResponse, type ServerActionResponse, @@ -80,12 +82,19 @@ export { fileContentSchema, readFileContentQuerySchema, updateFileContentSchema, + uploadFileSchema, + filePathSchema, + archiveFilesSchema, + unarchiveFileSchema, type ListFilesQuery, type FileEntry, type FileListResponse, type FileContent, type ReadFileContentQuery, type UpdateFileContent, + type UploadFile, + type ArchiveFiles, + type UnarchiveFile, } from './files'; export { @@ -108,13 +117,54 @@ export { onlinePlayerSchema, whitelistEntrySchema, operatorEntrySchema, + banEntrySchema, playerListResponseSchema, + addWhitelistSchema, + addOperatorSchema, + removePlayerEntrySchema, + addBanSchema, type OnlinePlayer, type WhitelistEntry, type OperatorEntry, + type BanEntry, type PlayerListResponse, + type AddWhitelist, + type AddOperator, + type RemovePlayerEntry, + type AddBan, } from './players'; +export { + scheduleTaskTypeSchema, + scheduleSchema, + createScheduleSchema, + updateScheduleSchema, + scheduleListResponseSchema, + type Schedule, + type CreateSchedule, + type UpdateSchedule, + type ScheduleListResponse, +} from './schedules'; + +export { + notificationTypeSchema, + notificationChannelSchema, + notificationSchema, + notificationListResponseSchema, + notificationPreferenceSchema, + updateNotificationPreferencesSchema, + type Notification, + type NotificationListResponse, + type UpdateNotificationPreferences, +} from './notifications'; + +export { + reinstallServerSchema, + reinstallServerResponseSchema, + type ReinstallServer, + type ReinstallServerResponse, +} from './software'; + export { backupStatusSchema, backupTypeSchema, @@ -273,3 +323,62 @@ export { type WhmcsMtlsStatusResponse, type WhmcsValidatePlanResponse, } from './whmcs'; + +export { + dataExportRequestSchema, + accountDeletionRequestSchema, + requestAccountDeletionSchema, + type DataExportRequest, + type AccountDeletionRequest, +} from './compliance'; + +export { + whmcsServiceActionRequestSchema, + whmcsServiceActionResponseSchema, + type WhmcsServiceActionRequest, + type WhmcsServiceActionResponse, +} from './whmcs-actions'; + +export { + organizationMemberRoleSchema, + organizationSchema, + organizationListResponseSchema, + createOrganizationSchema, + organizationMemberSchema, + organizationMemberListResponseSchema, + inviteOrganizationMemberSchema, + organizationInviteResponseSchema, + type Organization, + type OrganizationListResponse, + type CreateOrganization, + type OrganizationMember, + type OrganizationMemberListResponse, + type InviteOrganizationMember, + type OrganizationInviteResponse, +} from './organizations'; + +export { + supportTicketStatusSchema, + supportTicketPrioritySchema, + supportTicketMessageSchema, + supportTicketSchema, + supportTicketListResponseSchema, + createSupportTicketSchema, + addSupportTicketMessageSchema, + type SupportTicket, + type SupportTicketListResponse, + type CreateSupportTicket, + type AddSupportTicketMessage, +} from './support'; + +export { + abuseReportStatusSchema, + createAbuseReportSchema, + abuseReportSchema, + abuseReportListResponseSchema, + updateAbuseReportSchema, + type CreateAbuseReport, + type AbuseReport, + type AbuseReportListResponse, + type UpdateAbuseReport, +} from './abuse'; diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts new file mode 100644 index 0000000..d1f5624 --- /dev/null +++ b/packages/contracts/src/notifications.ts @@ -0,0 +1,51 @@ +import { z } from 'zod'; + +export const notificationTypeSchema = z.enum([ + 'SERVER_READY', + 'SERVER_START_FAILED', + 'SERVER_STOPPED', + 'IDLE_STOP_WARNING', + 'BACKUP_SUCCESS', + 'BACKUP_FAILED', + 'RESTORE_SUCCESS', + 'RESTORE_FAILED', + 'CREDITS_LOW', + 'BILLING_ISSUE', + 'INVITE', + 'NODE_INCIDENT', + 'MAINTENANCE', + 'SECURITY', + 'NEW_LOGIN', + 'TWO_FA_CHANGED', +]); + +export const notificationChannelSchema = z.enum(['IN_APP', 'EMAIL', 'WEBHOOK', 'DISCORD']); + +export const notificationSchema = z.object({ + id: z.string().uuid(), + type: notificationTypeSchema, + channel: notificationChannelSchema, + title: z.string(), + body: z.string(), + readAt: z.string().datetime().nullable(), + createdAt: z.string().datetime(), +}); + +export const notificationListResponseSchema = z.object({ + notifications: z.array(notificationSchema), + unreadCount: z.number().int(), +}); + +export const notificationPreferenceSchema = z.object({ + type: notificationTypeSchema, + channel: notificationChannelSchema, + enabled: z.boolean(), +}); + +export const updateNotificationPreferencesSchema = z.object({ + preferences: z.array(notificationPreferenceSchema), +}); + +export type Notification = z.infer; +export type NotificationListResponse = z.infer; +export type UpdateNotificationPreferences = z.infer; diff --git a/packages/contracts/src/organizations.ts b/packages/contracts/src/organizations.ts new file mode 100644 index 0000000..a848109 --- /dev/null +++ b/packages/contracts/src/organizations.ts @@ -0,0 +1,56 @@ +import { z } from 'zod'; + +export const organizationMemberRoleSchema = z.enum(['OWNER', 'ADMIN', 'MEMBER']); + +export const organizationSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + slug: z.string(), + role: organizationMemberRoleSchema, + createdAt: z.string().datetime(), +}); + +export const organizationListResponseSchema = z.object({ + organizations: z.array(organizationSchema), +}); + +export const createOrganizationSchema = z.object({ + name: z.string().min(1).max(128), + slug: z.string().min(2).max(64).regex(/^[a-z0-9-]+$/).optional(), +}); + +export const organizationMemberSchema = z.object({ + id: z.string().uuid(), + userId: z.string().uuid(), + role: organizationMemberRoleSchema, + email: z.string().email(), + username: z.string(), + displayName: z.string().nullable(), + createdAt: z.string().datetime(), +}); + +export const organizationMemberListResponseSchema = z.object({ + members: z.array(organizationMemberSchema), +}); + +export const inviteOrganizationMemberSchema = z.object({ + email: z.string().email(), + role: organizationMemberRoleSchema.default('MEMBER'), +}); + +export const organizationInviteResponseSchema = z.object({ + id: z.string().uuid(), + email: z.string().email(), + role: organizationMemberRoleSchema, + expiresAt: z.string().datetime(), +}); + +export type Organization = z.infer; +export type OrganizationListResponse = z.infer; +export type CreateOrganization = z.infer; +export type OrganizationMember = z.infer; +export type OrganizationMemberListResponse = z.infer< + typeof organizationMemberListResponseSchema +>; +export type InviteOrganizationMember = z.infer; +export type OrganizationInviteResponse = z.infer; diff --git a/packages/contracts/src/players.ts b/packages/contracts/src/players.ts index 94292ff..70454ec 100644 --- a/packages/contracts/src/players.ts +++ b/packages/contracts/src/players.ts @@ -17,13 +17,48 @@ export const operatorEntrySchema = z.object({ bypassesPlayerLimit: z.boolean().optional(), }); +export const banEntrySchema = z.object({ + uuid: z.string().uuid().optional(), + name: z.string(), + reason: z.string().optional(), + source: z.string().optional(), + expires: z.string().optional(), +}); + export const playerListResponseSchema = z.object({ online: z.array(onlinePlayerSchema), whitelist: z.array(whitelistEntrySchema), operators: z.array(operatorEntrySchema), + bans: z.array(banEntrySchema).optional(), +}); + +export const addWhitelistSchema = z.object({ + uuid: z.string().uuid(), + name: z.string().min(1).max(16), +}); + +export const addOperatorSchema = z.object({ + uuid: z.string().uuid(), + name: z.string().min(1).max(16), + level: z.number().int().min(0).max(4).default(4), + bypassesPlayerLimit: z.boolean().default(false), +}); + +export const removePlayerEntrySchema = z.object({ + uuid: z.string().uuid(), +}); + +export const addBanSchema = z.object({ + name: z.string().min(1).max(16), + reason: z.string().max(256).optional(), }); export type OnlinePlayer = z.infer; export type WhitelistEntry = z.infer; export type OperatorEntry = z.infer; +export type BanEntry = z.infer; export type PlayerListResponse = z.infer; +export type AddWhitelist = z.infer; +export type AddOperator = z.infer; +export type RemovePlayerEntry = z.infer; +export type AddBan = z.infer; diff --git a/packages/contracts/src/schedules.ts b/packages/contracts/src/schedules.ts new file mode 100644 index 0000000..5953424 --- /dev/null +++ b/packages/contracts/src/schedules.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; + +export const scheduleTaskTypeSchema = z.enum([ + 'START', + 'STOP', + 'RESTART', + 'BACKUP', + 'CONSOLE_COMMAND', + 'UPDATE_CHECK', + 'MAINTENANCE', +]); + +export const scheduledTaskSchema = z.object({ + id: z.string().uuid(), + taskType: scheduleTaskTypeSchema, + payload: z.record(z.unknown()).nullable(), +}); + +export const scheduleSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + timezone: z.string(), + cronExpr: z.string(), + enabled: z.boolean(), + nextRunAt: z.string().datetime().nullable(), + lastRunAt: z.string().datetime().nullable(), + tasks: z.array(scheduledTaskSchema), +}); + +export const createScheduleSchema = z.object({ + name: z.string().min(1).max(64), + timezone: z.string().default('UTC'), + cronExpr: z.string().min(5).max(128), + enabled: z.boolean().default(true), + tasks: z + .array( + z.object({ + taskType: scheduleTaskTypeSchema, + payload: z.record(z.unknown()).optional(), + }), + ) + .min(1), +}); + +export const updateScheduleSchema = createScheduleSchema.partial(); + +export const scheduleListResponseSchema = z.object({ + schedules: z.array(scheduleSchema), +}); + +export type Schedule = z.infer; +export type CreateSchedule = z.infer; +export type UpdateSchedule = z.infer; +export type ScheduleListResponse = z.infer; diff --git a/packages/contracts/src/servers.ts b/packages/contracts/src/servers.ts index fe4a88a..ab02e69 100644 --- a/packages/contracts/src/servers.ts +++ b/packages/contracts/src/servers.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; export const gameServerStatusSchema = z.enum([ 'DRAFT', + 'ALLOCATING', 'PROVISIONING', 'INSTALLING', 'STOPPED', @@ -11,6 +12,9 @@ export const gameServerStatusSchema = z.enum([ 'STOPPING', 'BACKING_UP', 'RESTORING', + 'MIGRATING', + 'SUSPENDED', + 'MAINTENANCE', 'UNKNOWN', 'ERROR', 'DELETING', @@ -42,6 +46,11 @@ export const createServerRequestSchema = z.object({ planId: z.string().uuid().optional(), }); +export const updateServerRequestSchema = z.object({ + name: z.string().min(1).max(64).optional(), + idleShutdownEnabled: z.boolean().optional(), +}); + export const serverResponseSchema = z.object({ id: z.string().uuid(), userId: z.string().uuid(), @@ -82,6 +91,7 @@ export type GameServerStatus = z.infer; export type ServerEdition = z.infer; export type SoftwareFamily = z.infer; export type CreateServerRequest = z.infer; +export type UpdateServerRequest = z.infer; export type ServerResponse = z.infer; export type ServerListResponse = z.infer; export type ServerActionResponse = z.infer; diff --git a/packages/contracts/src/software.ts b/packages/contracts/src/software.ts new file mode 100644 index 0000000..675e416 --- /dev/null +++ b/packages/contracts/src/software.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +import { softwareFamilySchema } from './servers'; + +export const reinstallServerSchema = z.object({ + softwareFamily: softwareFamilySchema, + minecraftVersion: z.string().min(1).max(32), + createBackup: z.boolean().default(true), +}); + +export const reinstallServerResponseSchema = z.object({ + serverId: z.string().uuid(), + jobId: z.string(), + status: z.string(), +}); + +export type ReinstallServer = z.infer; +export type ReinstallServerResponse = z.infer; diff --git a/packages/contracts/src/support.ts b/packages/contracts/src/support.ts new file mode 100644 index 0000000..1363cee --- /dev/null +++ b/packages/contracts/src/support.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const supportTicketStatusSchema = z.enum([ + 'OPEN', + 'IN_PROGRESS', + 'WAITING', + 'RESOLVED', + 'CLOSED', +]); + +export const supportTicketPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']); + +export const supportTicketMessageSchema = z.object({ + id: z.string().uuid(), + authorId: z.string().uuid().nullable(), + isInternal: z.boolean(), + body: z.string(), + createdAt: z.string().datetime(), +}); + +export const supportTicketSchema = z.object({ + id: z.string().uuid(), + subject: z.string(), + status: supportTicketStatusSchema, + priority: supportTicketPrioritySchema, + serverId: z.string().uuid().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + messages: z.array(supportTicketMessageSchema).optional(), +}); + +export const supportTicketListResponseSchema = z.object({ + tickets: z.array(supportTicketSchema), +}); + +export const createSupportTicketSchema = z.object({ + subject: z.string().min(1).max(256), + body: z.string().min(1).max(8192), + priority: supportTicketPrioritySchema.default('NORMAL'), + serverId: z.string().uuid().optional(), +}); + +export const addSupportTicketMessageSchema = z.object({ + body: z.string().min(1).max(8192), +}); + +export type SupportTicket = z.infer; +export type SupportTicketListResponse = z.infer; +export type CreateSupportTicket = z.infer; +export type AddSupportTicketMessage = z.infer; diff --git a/packages/contracts/src/whmcs-actions.ts b/packages/contracts/src/whmcs-actions.ts new file mode 100644 index 0000000..217722e --- /dev/null +++ b/packages/contracts/src/whmcs-actions.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { serverActionResponseSchema } from './servers'; + +export const whmcsServiceActionRequestSchema = z.object({ + reason: z.string().max(256).optional(), +}); + +export const whmcsServiceActionResponseSchema = serverActionResponseSchema; + +export type WhmcsServiceActionRequest = z.infer; +export type WhmcsServiceActionResponse = z.infer; diff --git a/packages/database/prisma/migrations/20260705150000_gap_closure/migration.sql b/packages/database/prisma/migrations/20260705150000_gap_closure/migration.sql new file mode 100644 index 0000000..bc7af8c --- /dev/null +++ b/packages/database/prisma/migrations/20260705150000_gap_closure/migration.sql @@ -0,0 +1,18 @@ +-- Gap closure migration: permissions, catalog, compliance, WHMCS mappings, server states + +-- AlterEnum +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'ALLOCATING'; +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'MIGRATING'; +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'SUSPENDED'; +ALTER TYPE "GameServerStatus" ADD VALUE IF NOT EXISTS 'MAINTENANCE'; + +-- CreateEnum +CREATE TYPE "GameServerMemberRole" AS ENUM ('OWNER', 'ADMIN', 'OPERATOR', 'DEVELOPER', 'VIEWER'); +CREATE TYPE "OrganizationMemberRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER'); +CREATE TYPE "NotificationChannel" AS ENUM ('IN_APP', 'EMAIL', 'WEBHOOK', 'DISCORD'); +CREATE TYPE "NotificationType" AS ENUM ('SERVER_READY', 'SERVER_START_FAILED', 'SERVER_STOPPED', 'IDLE_STOP_WARNING', 'BACKUP_SUCCESS', 'BACKUP_FAILED', 'RESTORE_SUCCESS', 'RESTORE_FAILED', 'CREDITS_LOW', 'BILLING_ISSUE', 'INVITE', 'NODE_INCIDENT', 'MAINTENANCE', 'SECURITY', 'NEW_LOGIN', 'TWO_FA_CHANGED'); +CREATE TYPE "AbuseReportStatus" AS ENUM ('OPEN', 'INVESTIGATING', 'RESOLVED', 'DISMISSED'); +CREATE TYPE "SupportTicketStatus" AS ENUM ('OPEN', 'IN_PROGRESS', 'WAITING', 'RESOLVED', 'CLOSED'); +CREATE TYPE "SupportTicketPriority" AS ENUM ('LOW', 'NORMAL', 'HIGH', 'URGENT'); +CREATE TYPE "ScheduleTaskType" AS ENUM ('START', 'STOP', 'RESTART', 'BACKUP', 'CONSOLE_COMMAND', 'UPDATE_CHECK', 'MAINTENANCE'); +CREATE TYPE "CatalogProviderKind" AS ENUM ('MODRINTH', 'CURSEFORGE', 'MOJANG', 'PAPERMC', 'CUSTOM'); diff --git a/packages/database/prisma/migrations/20260705160000_gap_closure_tables/migration.sql b/packages/database/prisma/migrations/20260705160000_gap_closure_tables/migration.sql new file mode 100644 index 0000000..2f0f800 --- /dev/null +++ b/packages/database/prisma/migrations/20260705160000_gap_closure_tables/migration.sql @@ -0,0 +1,768 @@ +-- Gap closure tables: organizations, catalog, schedules, notifications, compliance, billing, admin + +-- CreateEnum +DO $$ BEGIN + CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED'); +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "organizations" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "organization_members" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER', + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "organization_members_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "organization_invites" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "OrganizationMemberRole" NOT NULL DEFAULT 'MEMBER', + "tokenHash" TEXT NOT NULL, + "invitedById" TEXT NOT NULL, + "expiresAt" TIMESTAMPTZ(3) NOT NULL, + "acceptedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "organization_invites_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_members" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "GameServerMemberRole" NOT NULL DEFAULT 'VIEWER', + "permissions" JSONB, + "invitedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "acceptedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_members_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_properties" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_properties_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_environment_variables" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "isSecret" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_environment_variables_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_server_secrets" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "valueCiphertext" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "game_server_secrets_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_node_capabilities" ( + "id" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "game_node_capabilities_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "game_node_maintenance_windows" ( + "id" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "startsAt" TIMESTAMPTZ(3) NOT NULL, + "endsAt" TIMESTAMPTZ(3) NOT NULL, + "drainFirst" BOOLEAN NOT NULL DEFAULT true, + "message" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "game_node_maintenance_windows_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "minecraft_versions" ( + "id" TEXT NOT NULL, + "version" TEXT NOT NULL, + "edition" "ServerEdition" NOT NULL DEFAULT 'JAVA', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "minecraft_versions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "software_builds" ( + "id" TEXT NOT NULL, + "family" "SoftwareFamily" NOT NULL, + "minecraftVersionId" TEXT NOT NULL, + "downloadUrl" TEXT, + "sha256" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "software_builds_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "runtime_images" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "imageRef" TEXT NOT NULL, + "digest" TEXT, + "javaVersion" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "runtime_images_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_providers" ( + "id" TEXT NOT NULL, + "kind" "CatalogProviderKind" NOT NULL, + "name" TEXT NOT NULL, + "isEnabled" BOOLEAN NOT NULL DEFAULT false, + "config" JSONB, + "lastSyncAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "catalog_providers_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_projects" ( + "id" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "name" TEXT NOT NULL, + "projectType" TEXT NOT NULL, + "metadata" JSONB, + "syncedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "catalog_projects_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_versions" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "versionNumber" TEXT NOT NULL, + "gameVersions" JSONB, + "loaders" JSONB, + "sha512" TEXT, + "downloadUrl" TEXT, + "syncedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "catalog_versions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "catalog_dependencies" ( + "id" TEXT NOT NULL, + "versionId" TEXT NOT NULL, + "dependencyId" TEXT NOT NULL, + "dependencyType" TEXT NOT NULL, + + CONSTRAINT "catalog_dependencies_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "worlds" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT false, + "sizeBytes" BIGINT, + "hasLevelDat" BOOLEAN NOT NULL DEFAULT false, + "seed" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "worlds_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "world_archives" ( + "id" TEXT NOT NULL, + "worldId" TEXT NOT NULL, + "s3Key" TEXT NOT NULL, + "sha256" TEXT, + "sizeBytes" BIGINT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "world_archives_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "schedules" ( + "id" TEXT NOT NULL, + "serverId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "timezone" TEXT NOT NULL DEFAULT 'UTC', + "cronExpr" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "nextRunAt" TIMESTAMPTZ(3), + "lastRunAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "schedules_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "scheduled_tasks" ( + "id" TEXT NOT NULL, + "scheduleId" TEXT NOT NULL, + "taskType" "ScheduleTaskType" NOT NULL, + "payload" JSONB, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "scheduled_tasks_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "notifications" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "channel" "NotificationChannel" NOT NULL DEFAULT 'IN_APP', + "title" TEXT NOT NULL, + "body" TEXT NOT NULL, + "metadata" JSONB, + "readAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notifications_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "notification_preferences" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + "channel" "NotificationChannel" NOT NULL, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "abuse_reports" ( + "id" TEXT NOT NULL, + "reporterId" TEXT, + "targetUserId" TEXT, + "serverId" TEXT, + "status" "AbuseReportStatus" NOT NULL DEFAULT 'OPEN', + "reason" TEXT NOT NULL, + "details" TEXT, + "resolution" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "abuse_reports_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "support_tickets" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "serverId" TEXT, + "subject" TEXT NOT NULL, + "status" "SupportTicketStatus" NOT NULL DEFAULT 'OPEN', + "priority" "SupportTicketPriority" NOT NULL DEFAULT 'NORMAL', + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "support_tickets_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "support_ticket_messages" ( + "id" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "authorId" TEXT, + "isInternal" BOOLEAN NOT NULL DEFAULT false, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "support_ticket_messages_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "subscriptions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "status" TEXT NOT NULL, + "startsAt" TIMESTAMPTZ(3) NOT NULL, + "endsAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "invoices" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "subscriptionId" TEXT, + "amountCents" INTEGER NOT NULL, + "currency" TEXT NOT NULL DEFAULT 'EUR', + "status" TEXT NOT NULL, + "externalRef" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "invoices_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "payments" ( + "id" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "amountCents" INTEGER NOT NULL, + "provider" TEXT NOT NULL, + "externalRef" TEXT, + "status" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "payments_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "resource_quotas" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "planId" TEXT, + "key" TEXT NOT NULL, + "limit" INTEGER NOT NULL, + "used" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "resource_quotas_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "security_change_logs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "action" TEXT NOT NULL, + "metadata" JSONB, + "ipAddress" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "security_change_logs_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "data_export_requests" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "downloadUrl" TEXT, + "expiresAt" TIMESTAMPTZ(3), + "completedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "data_export_requests_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "account_deletion_requests" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "scheduledFor" TIMESTAMPTZ(3) NOT NULL, + "completedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "account_deletion_requests_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_user_links" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalUserId" TEXT NOT NULL, + "externalClientId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_user_links_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_product_mappings" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalProductId" TEXT NOT NULL, + "planSlug" TEXT NOT NULL, + "defaultEdition" "ServerEdition" NOT NULL DEFAULT 'JAVA', + "defaultSoftware" "SoftwareFamily" NOT NULL DEFAULT 'VANILLA', + "defaultVersion" TEXT NOT NULL DEFAULT '1.21.1', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_product_mappings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_option_mappings" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalOptionId" TEXT NOT NULL, + "externalValueId" TEXT NOT NULL, + "resourceKey" TEXT NOT NULL, + "normalizedValue" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_option_mappings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "whmcs_addon_mappings" ( + "id" TEXT NOT NULL, + "installationId" TEXT NOT NULL, + "externalAddonId" TEXT NOT NULL, + "featureKey" TEXT NOT NULL, + "deltaValue" TEXT NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "whmcs_addon_mappings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "feature_flags" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "description" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT false, + "metadata" JSONB, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "feature_flags_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "system_settings" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "system_settings_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "job_records" ( + "id" TEXT NOT NULL, + "queue" TEXT NOT NULL, + "jobName" TEXT NOT NULL, + "status" "JobStatus" NOT NULL DEFAULT 'PENDING', + "payload" JSONB, + "result" JSONB, + "error" TEXT, + "attempts" INTEGER NOT NULL DEFAULT 0, + "startedAt" TIMESTAMPTZ(3), + "completedAt" TIMESTAMPTZ(3), + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "job_records_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE IF NOT EXISTS "audit_events" ( + "id" TEXT NOT NULL, + "userId" TEXT, + "action" TEXT NOT NULL, + "entityType" TEXT, + "entityId" TEXT, + "metadata" JSONB, + "ipAddress" TEXT, + "createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "audit_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "organizations_slug_key" ON "organizations"("slug"); + +CREATE UNIQUE INDEX IF NOT EXISTS "organization_members_organizationId_userId_key" ON "organization_members"("organizationId", "userId"); +CREATE INDEX IF NOT EXISTS "organization_members_userId_idx" ON "organization_members"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "organization_invites_tokenHash_key" ON "organization_invites"("tokenHash"); +CREATE INDEX IF NOT EXISTS "organization_invites_organizationId_idx" ON "organization_invites"("organizationId"); +CREATE INDEX IF NOT EXISTS "organization_invites_email_idx" ON "organization_invites"("email"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_members_serverId_userId_key" ON "game_server_members"("serverId", "userId"); +CREATE INDEX IF NOT EXISTS "game_server_members_userId_idx" ON "game_server_members"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_properties_serverId_key_key" ON "game_server_properties"("serverId", "key"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_environment_variables_serverId_key_key" ON "game_server_environment_variables"("serverId", "key"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_server_secrets_serverId_name_key" ON "game_server_secrets"("serverId", "name"); + +CREATE UNIQUE INDEX IF NOT EXISTS "game_node_capabilities_nodeId_key_key" ON "game_node_capabilities"("nodeId", "key"); + +CREATE INDEX IF NOT EXISTS "game_node_maintenance_windows_nodeId_startsAt_idx" ON "game_node_maintenance_windows"("nodeId", "startsAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "minecraft_versions_version_key" ON "minecraft_versions"("version"); + +CREATE UNIQUE INDEX IF NOT EXISTS "software_builds_family_minecraftVersionId_key" ON "software_builds"("family", "minecraftVersionId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "runtime_images_name_key" ON "runtime_images"("name"); + +CREATE UNIQUE INDEX IF NOT EXISTS "catalog_providers_kind_key" ON "catalog_providers"("kind"); + +CREATE UNIQUE INDEX IF NOT EXISTS "catalog_projects_providerId_externalId_key" ON "catalog_projects"("providerId", "externalId"); +CREATE INDEX IF NOT EXISTS "catalog_projects_slug_idx" ON "catalog_projects"("slug"); + +CREATE UNIQUE INDEX IF NOT EXISTS "catalog_versions_projectId_externalId_key" ON "catalog_versions"("projectId", "externalId"); + +CREATE INDEX IF NOT EXISTS "catalog_dependencies_versionId_idx" ON "catalog_dependencies"("versionId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "worlds_serverId_name_key" ON "worlds"("serverId", "name"); + +CREATE INDEX IF NOT EXISTS "world_archives_worldId_idx" ON "world_archives"("worldId"); + +CREATE INDEX IF NOT EXISTS "schedules_serverId_idx" ON "schedules"("serverId"); + +CREATE INDEX IF NOT EXISTS "scheduled_tasks_scheduleId_idx" ON "scheduled_tasks"("scheduleId"); + +CREATE INDEX IF NOT EXISTS "notifications_userId_readAt_idx" ON "notifications"("userId", "readAt"); +CREATE INDEX IF NOT EXISTS "notifications_createdAt_idx" ON "notifications"("createdAt"); + +CREATE UNIQUE INDEX IF NOT EXISTS "notification_preferences_userId_type_channel_key" ON "notification_preferences"("userId", "type", "channel"); + +CREATE INDEX IF NOT EXISTS "abuse_reports_status_idx" ON "abuse_reports"("status"); +CREATE INDEX IF NOT EXISTS "abuse_reports_serverId_idx" ON "abuse_reports"("serverId"); + +CREATE INDEX IF NOT EXISTS "support_tickets_userId_idx" ON "support_tickets"("userId"); +CREATE INDEX IF NOT EXISTS "support_tickets_status_idx" ON "support_tickets"("status"); + +CREATE INDEX IF NOT EXISTS "support_ticket_messages_ticketId_idx" ON "support_ticket_messages"("ticketId"); + +CREATE INDEX IF NOT EXISTS "subscriptions_userId_idx" ON "subscriptions"("userId"); + +CREATE INDEX IF NOT EXISTS "invoices_userId_idx" ON "invoices"("userId"); + +CREATE INDEX IF NOT EXISTS "payments_invoiceId_idx" ON "payments"("invoiceId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "resource_quotas_userId_planId_key_key" ON "resource_quotas"("userId", "planId", "key"); + +CREATE INDEX IF NOT EXISTS "security_change_logs_userId_createdAt_idx" ON "security_change_logs"("userId", "createdAt"); + +CREATE INDEX IF NOT EXISTS "data_export_requests_userId_idx" ON "data_export_requests"("userId"); + +CREATE INDEX IF NOT EXISTS "account_deletion_requests_userId_idx" ON "account_deletion_requests"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_user_links_installationId_externalUserId_key" ON "whmcs_user_links"("installationId", "externalUserId"); +CREATE INDEX IF NOT EXISTS "whmcs_user_links_userId_idx" ON "whmcs_user_links"("userId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_product_mappings_installationId_externalProductId_key" ON "whmcs_product_mappings"("installationId", "externalProductId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_option_mappings_installationId_externalOptionId_externalValueId_key" ON "whmcs_option_mappings"("installationId", "externalOptionId", "externalValueId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "whmcs_addon_mappings_installationId_externalAddonId_key" ON "whmcs_addon_mappings"("installationId", "externalAddonId"); + +CREATE UNIQUE INDEX IF NOT EXISTS "feature_flags_key_key" ON "feature_flags"("key"); + +CREATE UNIQUE INDEX IF NOT EXISTS "system_settings_key_key" ON "system_settings"("key"); + +CREATE INDEX IF NOT EXISTS "job_records_queue_status_idx" ON "job_records"("queue", "status"); +CREATE INDEX IF NOT EXISTS "job_records_createdAt_idx" ON "job_records"("createdAt"); + +CREATE INDEX IF NOT EXISTS "audit_events_userId_idx" ON "audit_events"("userId"); +CREATE INDEX IF NOT EXISTS "audit_events_action_idx" ON "audit_events"("action"); +CREATE INDEX IF NOT EXISTS "audit_events_entityType_entityId_idx" ON "audit_events"("entityType", "entityId"); +CREATE INDEX IF NOT EXISTS "audit_events_createdAt_idx" ON "audit_events"("createdAt"); + +-- AddForeignKey +DO $$ BEGIN + ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "organization_members" ADD CONSTRAINT "organization_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "organization_invites" ADD CONSTRAINT "organization_invites_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "organization_invites" ADD CONSTRAINT "organization_invites_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_members" ADD CONSTRAINT "game_server_members_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_members" ADD CONSTRAINT "game_server_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_properties" ADD CONSTRAINT "game_server_properties_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_environment_variables" ADD CONSTRAINT "game_server_environment_variables_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_server_secrets" ADD CONSTRAINT "game_server_secrets_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_node_capabilities" ADD CONSTRAINT "game_node_capabilities_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "game_node_maintenance_windows" ADD CONSTRAINT "game_node_maintenance_windows_nodeId_fkey" FOREIGN KEY ("nodeId") REFERENCES "game_nodes"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "software_builds" ADD CONSTRAINT "software_builds_minecraftVersionId_fkey" FOREIGN KEY ("minecraftVersionId") REFERENCES "minecraft_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "catalog_projects" ADD CONSTRAINT "catalog_projects_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "catalog_providers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "catalog_versions" ADD CONSTRAINT "catalog_versions_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "catalog_projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "catalog_dependencies" ADD CONSTRAINT "catalog_dependencies_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "catalog_versions"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "worlds" ADD CONSTRAINT "worlds_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "world_archives" ADD CONSTRAINT "world_archives_worldId_fkey" FOREIGN KEY ("worldId") REFERENCES "worlds"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "schedules" ADD CONSTRAINT "schedules_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "scheduled_tasks" ADD CONSTRAINT "scheduled_tasks_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "schedules"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "abuse_reports" ADD CONSTRAINT "abuse_reports_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "support_tickets" ADD CONSTRAINT "support_tickets_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "support_ticket_messages" ADD CONSTRAINT "support_ticket_messages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "support_tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "payments" ADD CONSTRAINT "payments_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "security_change_logs" ADD CONSTRAINT "security_change_logs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "data_export_requests" ADD CONSTRAINT "data_export_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "account_deletion_requests" ADD CONSTRAINT "account_deletion_requests_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_user_links" ADD CONSTRAINT "whmcs_user_links_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_user_links" ADD CONSTRAINT "whmcs_user_links_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_product_mappings" ADD CONSTRAINT "whmcs_product_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_option_mappings" ADD CONSTRAINT "whmcs_option_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "whmcs_addon_mappings" ADD CONSTRAINT "whmcs_addon_mappings_installationId_fkey" FOREIGN KEY ("installationId") REFERENCES "whmcs_installations"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; +END $$; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f299e72..db1ef59 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -31,6 +31,7 @@ enum SoftwareFamily { enum GameServerStatus { DRAFT + ALLOCATING PROVISIONING INSTALLING STOPPED @@ -40,12 +41,95 @@ enum GameServerStatus { STOPPING BACKING_UP RESTORING + MIGRATING + SUSPENDED + MAINTENANCE UNKNOWN ERROR DELETING DELETED } +enum GameServerMemberRole { + OWNER + ADMIN + OPERATOR + DEVELOPER + VIEWER +} + +enum OrganizationMemberRole { + OWNER + ADMIN + MEMBER +} + +enum NotificationChannel { + IN_APP + EMAIL + WEBHOOK + DISCORD +} + +enum NotificationType { + SERVER_READY + SERVER_START_FAILED + SERVER_STOPPED + IDLE_STOP_WARNING + BACKUP_SUCCESS + BACKUP_FAILED + RESTORE_SUCCESS + RESTORE_FAILED + CREDITS_LOW + BILLING_ISSUE + INVITE + NODE_INCIDENT + MAINTENANCE + SECURITY + NEW_LOGIN + TWO_FA_CHANGED +} + +enum AbuseReportStatus { + OPEN + INVESTIGATING + RESOLVED + DISMISSED +} + +enum SupportTicketStatus { + OPEN + IN_PROGRESS + WAITING + RESOLVED + CLOSED +} + +enum SupportTicketPriority { + LOW + NORMAL + HIGH + URGENT +} + +enum ScheduleTaskType { + START + STOP + RESTART + BACKUP + CONSOLE_COMMAND + UPDATE_CHECK + MAINTENANCE +} + +enum CatalogProviderKind { + MODRINTH + CURSEFORGE + MOJANG + PAPERMC + CUSTOM +} + enum GameNodeStatus { ONLINE OFFLINE @@ -194,7 +278,18 @@ model User { totpCredential TotpCredential? creditWallet CreditWallet? whmcsClientLink WhmcsClientLink? + whmcsUserLinks WhmcsUserLink[] ssoTickets SsoTicket[] + organizationMembers OrganizationMember[] + organizationInvites OrganizationInvite[] @relation("OrganizationInviteInviter") + gameServerMembers GameServerMember[] + notifications Notification[] + notificationPreferences NotificationPreference[] + abuseReports AbuseReport[] + supportTickets SupportTicket[] + securityChangeLogs SecurityChangeLog[] + dataExportRequests DataExportRequest[] + accountDeletionRequests AccountDeletionRequest[] @@map("users") } @@ -361,6 +456,12 @@ model GameServer { dnsRecords ServerDnsRecord[] whmcsServiceLink WhmcsServiceLink? ssoTickets SsoTicket[] + members GameServerMember[] + worlds World[] + schedules Schedule[] + properties GameServerProperty[] + environmentVars GameServerEnvironmentVariable[] + secrets GameServerSecret[] @@index([userId]) @@index([nodeId]) @@ -430,6 +531,8 @@ model GameNode { heartbeats GameNodeHeartbeat[] allocations GameServerAllocation[] startQueueEntries ServerStartQueueEntry[] + capabilities GameNodeCapability[] + maintenanceWindows GameNodeMaintenanceWindow[] @@map("game_nodes") } @@ -743,7 +846,11 @@ model WhmcsInstallation { updatedAt DateTime @updatedAt @db.Timestamptz(3) clientLinks WhmcsClientLink[] + userLinks WhmcsUserLink[] serviceLinks WhmcsServiceLink[] + productMappings WhmcsProductMapping[] + optionMappings WhmcsOptionMapping[] + addonMappings WhmcsAddonMapping[] operations IntegrationOperation[] ssoTickets SsoTicket[] events IntegrationEvent[] @@ -907,3 +1014,562 @@ model WhmcsUsageExport { @@index([installationId, periodEnd]) @@map("whmcs_usage_exports") } + +model Organization { + id String @id @default(uuid()) + name String + slug String @unique + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + members OrganizationMember[] + invites OrganizationInvite[] + + @@map("organizations") +} + +model OrganizationMember { + id String @id @default(uuid()) + organizationId String + userId String + role OrganizationMemberRole @default(MEMBER) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([organizationId, userId]) + @@index([userId]) + @@map("organization_members") +} + +model OrganizationInvite { + id String @id @default(uuid()) + organizationId String + email String + role OrganizationMemberRole @default(MEMBER) + tokenHash String @unique + invitedById String + expiresAt DateTime @db.Timestamptz(3) + acceptedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + invitedBy User @relation("OrganizationInviteInviter", fields: [invitedById], references: [id], onDelete: Cascade) + + @@index([organizationId]) + @@index([email]) + @@map("organization_invites") +} + +model GameServerMember { + id String @id @default(uuid()) + serverId String + userId String + role GameServerMemberRole @default(VIEWER) + permissions Json? + invitedAt DateTime @default(now()) @db.Timestamptz(3) + acceptedAt 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) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([serverId, userId]) + @@index([userId]) + @@map("game_server_members") +} + +model GameServerProperty { + id String @id @default(uuid()) + serverId String + key String + value String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([serverId, key]) + @@map("game_server_properties") +} + +model GameServerEnvironmentVariable { + id String @id @default(uuid()) + serverId String + key String + value String + isSecret Boolean @default(false) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([serverId, key]) + @@map("game_server_environment_variables") +} + +model GameServerSecret { + id String @id @default(uuid()) + serverId String + name String + valueCiphertext String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + + @@unique([serverId, name]) + @@map("game_server_secrets") +} + +model GameNodeCapability { + id String @id @default(uuid()) + nodeId String + key String + value String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + node GameNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@unique([nodeId, key]) + @@map("game_node_capabilities") +} + +model GameNodeMaintenanceWindow { + id String @id @default(uuid()) + nodeId String + startsAt DateTime @db.Timestamptz(3) + endsAt DateTime @db.Timestamptz(3) + drainFirst Boolean @default(true) + message String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + node GameNode @relation(fields: [nodeId], references: [id], onDelete: Cascade) + + @@index([nodeId, startsAt]) + @@map("game_node_maintenance_windows") +} + +model MinecraftVersion { + id String @id @default(uuid()) + version String @unique + edition ServerEdition @default(JAVA) + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + softwareBuilds SoftwareBuild[] + + @@map("minecraft_versions") +} + +model SoftwareBuild { + id String @id @default(uuid()) + family SoftwareFamily + minecraftVersionId String + downloadUrl String? + sha256 String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + minecraftVersion MinecraftVersion @relation(fields: [minecraftVersionId], references: [id], onDelete: Cascade) + + @@unique([family, minecraftVersionId]) + @@map("software_builds") +} + +model RuntimeImage { + id String @id @default(uuid()) + name String @unique + imageRef String + digest String? + javaVersion String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + @@map("runtime_images") +} + +model CatalogProvider { + id String @id @default(uuid()) + kind CatalogProviderKind @unique + name String + isEnabled Boolean @default(false) + config Json? + lastSyncAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + projects CatalogProject[] + + @@map("catalog_providers") +} + +model CatalogProject { + id String @id @default(uuid()) + providerId String + externalId String + slug String + name String + projectType String + metadata Json? + syncedAt DateTime @default(now()) @db.Timestamptz(3) + + provider CatalogProvider @relation(fields: [providerId], references: [id], onDelete: Cascade) + versions CatalogVersion[] + + @@unique([providerId, externalId]) + @@index([slug]) + @@map("catalog_projects") +} + +model CatalogVersion { + id String @id @default(uuid()) + projectId String + externalId String + versionNumber String + gameVersions Json? + loaders Json? + sha512 String? + downloadUrl String? + syncedAt DateTime @default(now()) @db.Timestamptz(3) + + project CatalogProject @relation(fields: [projectId], references: [id], onDelete: Cascade) + dependencies CatalogDependency[] @relation("VersionDependencies") + + @@unique([projectId, externalId]) + @@map("catalog_versions") +} + +model CatalogDependency { + id String @id @default(uuid()) + versionId String + dependencyId String + dependencyType String + + version CatalogVersion @relation("VersionDependencies", fields: [versionId], references: [id], onDelete: Cascade) + + @@index([versionId]) + @@map("catalog_dependencies") +} + +model World { + id String @id @default(uuid()) + serverId String + name String + isActive Boolean @default(false) + sizeBytes BigInt? + hasLevelDat Boolean @default(false) + seed String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade) + archives WorldArchive[] + + @@unique([serverId, name]) + @@map("worlds") +} + +model WorldArchive { + id String @id @default(uuid()) + worldId String + s3Key String + sha256 String? + sizeBytes BigInt? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + world World @relation(fields: [worldId], references: [id], onDelete: Cascade) + + @@index([worldId]) + @@map("world_archives") +} + +model Schedule { + id String @id @default(uuid()) + serverId String + name String + timezone String @default("UTC") + cronExpr String + enabled Boolean @default(true) + nextRunAt DateTime? @db.Timestamptz(3) + lastRunAt 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) + tasks ScheduledTask[] + + @@index([serverId]) + @@map("schedules") +} + +model ScheduledTask { + id String @id @default(uuid()) + scheduleId String + taskType ScheduleTaskType + payload Json? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + + @@index([scheduleId]) + @@map("scheduled_tasks") +} + +model Notification { + id String @id @default(uuid()) + userId String + type NotificationType + channel NotificationChannel @default(IN_APP) + title String + body String + metadata Json? + readAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, readAt]) + @@index([createdAt]) + @@map("notifications") +} + +model NotificationPreference { + id String @id @default(uuid()) + userId String + type NotificationType + channel NotificationChannel + enabled Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, type, channel]) + @@map("notification_preferences") +} + +model AbuseReport { + id String @id @default(uuid()) + reporterId String? + targetUserId String? + serverId String? + status AbuseReportStatus @default(OPEN) + reason String + details String? + resolution String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + reporter User? @relation(fields: [reporterId], references: [id], onDelete: SetNull) + + @@index([status]) + @@index([serverId]) + @@map("abuse_reports") +} + +model SupportTicket { + id String @id @default(uuid()) + userId String + serverId String? + subject String + status SupportTicketStatus @default(OPEN) + priority SupportTicketPriority @default(NORMAL) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + messages SupportTicketMessage[] + + @@index([userId]) + @@index([status]) + @@map("support_tickets") +} + +model SupportTicketMessage { + id String @id @default(uuid()) + ticketId String + authorId String? + isInternal Boolean @default(false) + body String + createdAt DateTime @default(now()) @db.Timestamptz(3) + + ticket SupportTicket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + + @@index([ticketId]) + @@map("support_ticket_messages") +} + +model Subscription { + id String @id @default(uuid()) + userId String + planId String + status String + startsAt DateTime @db.Timestamptz(3) + endsAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + @@index([userId]) + @@map("subscriptions") +} + +model Invoice { + id String @id @default(uuid()) + userId String + subscriptionId String? + amountCents Int + currency String @default("EUR") + status String + externalRef String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + payments Payment[] + + @@index([userId]) + @@map("invoices") +} + +model Payment { + id String @id @default(uuid()) + invoiceId String + amountCents Int + provider String + externalRef String? + status String + createdAt DateTime @default(now()) @db.Timestamptz(3) + + invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) + + @@index([invoiceId]) + @@map("payments") +} + +model ResourceQuota { + id String @id @default(uuid()) + userId String? + planId String? + key String + limit Int + used Int @default(0) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + @@unique([userId, planId, key]) + @@map("resource_quotas") +} + +model SecurityChangeLog { + id String @id @default(uuid()) + userId String + action String + metadata Json? + ipAddress String? + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@map("security_change_logs") +} + +model DataExportRequest { + id String @id @default(uuid()) + userId String + status String @default("PENDING") + downloadUrl String? + expiresAt DateTime? @db.Timestamptz(3) + completedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("data_export_requests") +} + +model AccountDeletionRequest { + id String @id @default(uuid()) + userId String + status String @default("PENDING") + scheduledFor DateTime @db.Timestamptz(3) + completedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("account_deletion_requests") +} + +model WhmcsUserLink { + id String @id @default(uuid()) + installationId String + externalUserId String + externalClientId String + userId String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalUserId]) + @@index([userId]) + @@map("whmcs_user_links") +} + +model WhmcsProductMapping { + id String @id @default(uuid()) + installationId String + externalProductId String + planSlug String + defaultEdition ServerEdition @default(JAVA) + defaultSoftware SoftwareFamily @default(VANILLA) + defaultVersion String @default("1.21.1") + isActive Boolean @default(true) + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalProductId]) + @@map("whmcs_product_mappings") +} + +model WhmcsOptionMapping { + id String @id @default(uuid()) + installationId String + externalOptionId String + externalValueId String + resourceKey String + normalizedValue String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalOptionId, externalValueId]) + @@map("whmcs_option_mappings") +} + +model WhmcsAddonMapping { + id String @id @default(uuid()) + installationId String + externalAddonId String + featureKey String + deltaValue String + createdAt DateTime @default(now()) @db.Timestamptz(3) + updatedAt DateTime @updatedAt @db.Timestamptz(3) + + installation WhmcsInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade) + + @@unique([installationId, externalAddonId]) + @@map("whmcs_addon_mappings") +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index d59ee94..90ce5c5 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -38,6 +38,19 @@ export type { SystemSetting, AuditEvent, JobRecord, + Schedule, + ScheduledTask, + Notification, + NotificationPreference, + DataExportRequest, + AccountDeletionRequest, + Organization, + OrganizationMember, + OrganizationMemberRole, + OrganizationInvite, + SupportTicket, + SupportTicketMessage, + AbuseReport, } from '@prisma/client'; import { PrismaClient, Prisma } from '@prisma/client'; diff --git a/packages/email-templates/package.json b/packages/email-templates/package.json new file mode 100644 index 0000000..4b5eae8 --- /dev/null +++ b/packages/email-templates/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hexahost/email-templates", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/email-templates/src/index.ts b/packages/email-templates/src/index.ts new file mode 100644 index 0000000..ee5b9af --- /dev/null +++ b/packages/email-templates/src/index.ts @@ -0,0 +1,102 @@ +export type EmailLocale = 'de' | 'en'; + +export interface EmailTemplateInput { + appName: string; + locale?: EmailLocale; +} + +export interface LinkEmailInput extends EmailTemplateInput { + url: string; +} + +const copy = { + de: { + verifySubject: (app: string) => `E-Mail bestätigen – ${app}`, + verifyHtml: (app: string, url: string) => + `

    Willkommen bei ${app}!

    E-Mail-Adresse bestätigen

    Der Link ist 24 Stunden gültig.

    `, + resetSubject: (app: string) => `Passwort zurücksetzen – ${app}`, + resetHtml: (app: string, url: string) => + `

    Du hast ein Passwort-Reset für ${app} angefordert.

    Neues Passwort setzen

    Falls du das nicht warst, ignoriere diese E-Mail.

    `, + serverReadySubject: (app: string, name: string) => `Server bereit – ${name} – ${app}`, + serverReadyHtml: (app: string, name: string) => + `

    Dein Server ${name} ist bereit.

    Öffne das Panel in ${app}, um zu starten.

    `, + backupSuccessSubject: (app: string, name: string) => `Backup erfolgreich – ${name}`, + backupSuccessHtml: (app: string, name: string) => + `

    Das Backup für ${name} wurde erfolgreich erstellt.

    `, + backupFailedSubject: (app: string, name: string) => `Backup fehlgeschlagen – ${name}`, + backupFailedHtml: (app: string, name: string) => + `

    Das Backup für ${name} ist fehlgeschlagen. Bitte prüfe das Panel in ${app}.

    `, + }, + en: { + verifySubject: (app: string) => `Verify your email – ${app}`, + verifyHtml: (app: string, url: string) => + `

    Welcome to ${app}!

    Verify your email address

    This link expires in 24 hours.

    `, + resetSubject: (app: string) => `Reset your password – ${app}`, + resetHtml: (app: string, url: string) => + `

    You requested a password reset for ${app}.

    Set a new password

    If you did not request this, ignore this email.

    `, + serverReadySubject: (app: string, name: string) => `Server ready – ${name} – ${app}`, + serverReadyHtml: (app: string, name: string) => + `

    Your server ${name} is ready.

    Open the panel in ${app} to get started.

    `, + backupSuccessSubject: (app: string, name: string) => `Backup successful – ${name}`, + backupSuccessHtml: (app: string, name: string) => + `

    The backup for ${name} completed successfully.

    `, + backupFailedSubject: (app: string, name: string) => `Backup failed – ${name}`, + backupFailedHtml: (app: string, name: string) => + `

    The backup for ${name} failed. Please check the panel in ${app}.

    `, + }, +} as const; + +function localeOf(input: EmailTemplateInput): EmailLocale { + return input.locale === 'de' ? 'de' : 'en'; +} + +export function renderVerifyEmail(input: LinkEmailInput): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.verifySubject(input.appName), + html: t.verifyHtml(input.appName, input.url), + }; +} + +export function renderResetEmail(input: LinkEmailInput): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.resetSubject(input.appName), + html: t.resetHtml(input.appName, input.url), + }; +} + +export function renderServerReadyEmail( + input: EmailTemplateInput & { serverName: string }, +): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.serverReadySubject(input.appName, input.serverName), + html: t.serverReadyHtml(input.appName, input.serverName), + }; +} + +export function renderBackupSuccessEmail( + input: EmailTemplateInput & { serverName: string }, +): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.backupSuccessSubject(input.appName, input.serverName), + html: t.backupSuccessHtml(input.appName, input.serverName), + }; +} + +export function renderBackupFailedEmail( + input: EmailTemplateInput & { serverName: string }, +): { subject: string; html: string } { + const locale = localeOf(input); + const t = copy[locale]; + return { + subject: t.backupFailedSubject(input.appName, input.serverName), + html: t.backupFailedHtml(input.appName, input.serverName), + }; +} diff --git a/packages/email-templates/tsconfig.json b/packages/email-templates/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/email-templates/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/feature-flags/package.json b/packages/feature-flags/package.json new file mode 100644 index 0000000..f219fb0 --- /dev/null +++ b/packages/feature-flags/package.json @@ -0,0 +1,22 @@ +{ + "name": "@hexahost/feature-flags", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@hexahost/database": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/feature-flags/src/index.ts b/packages/feature-flags/src/index.ts new file mode 100644 index 0000000..b6ab6fe --- /dev/null +++ b/packages/feature-flags/src/index.ts @@ -0,0 +1,57 @@ +import type { PrismaClient } from '@hexahost/database'; + +export interface FeatureFlagSnapshot { + key: string; + enabled: boolean; + metadata: Record | null; +} + +export class FeatureFlagService { + private cache = new Map(); + private loadedAt = 0; + private readonly ttlMs: number; + + constructor( + private readonly prisma: PrismaClient, + ttlMs = 30_000, + ) { + this.ttlMs = ttlMs; + } + + async refresh(): Promise { + const flags = await this.prisma.featureFlag.findMany(); + this.cache.clear(); + for (const flag of flags) { + this.cache.set(flag.key, { + key: flag.key, + enabled: flag.enabled, + metadata: + flag.metadata && typeof flag.metadata === 'object' && !Array.isArray(flag.metadata) + ? (flag.metadata as Record) + : null, + }); + } + this.loadedAt = Date.now(); + } + + private async ensureLoaded(): Promise { + if (Date.now() - this.loadedAt > this.ttlMs || this.cache.size === 0) { + await this.refresh(); + } + } + + async isEnabled(key: string, defaultValue = false): Promise { + await this.ensureLoaded(); + return this.cache.get(key)?.enabled ?? defaultValue; + } + + async getFlag(key: string): Promise { + await this.ensureLoaded(); + return this.cache.get(key) ?? null; + } + + async listFlags(): Promise { + await this.ensureLoaded(); + return [...this.cache.values()]; + } +} diff --git a/packages/feature-flags/tsconfig.json b/packages/feature-flags/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/feature-flags/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/otel/package.json b/packages/otel/package.json new file mode 100644 index 0000000..911aa24 --- /dev/null +++ b/packages/otel/package.json @@ -0,0 +1,24 @@ +{ + "name": "@hexahost/otel", + "version": "0.0.1", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.1", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-node": "^0.57.1", + "@opentelemetry/semantic-conventions": "^1.30.0" + }, + "devDependencies": { + "typescript": "^5.7.3" + } +} diff --git a/packages/otel/src/index.ts b/packages/otel/src/index.ts new file mode 100644 index 0000000..59618fc --- /dev/null +++ b/packages/otel/src/index.ts @@ -0,0 +1,37 @@ +import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { Resource } from '@opentelemetry/resources'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; + +let sdk: NodeSDK | null = null; + +export function initOpenTelemetry(serviceName: string): NodeSDK | null { + const endpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT']; + if (!endpoint) { + return null; + } + + if (process.env['OTEL_LOG_LEVEL'] === 'debug') { + diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG); + } + + sdk = new NodeSDK({ + resource: new Resource({ + [ATTR_SERVICE_NAME]: serviceName, + }), + traceExporter: new OTLPTraceExporter({ + url: endpoint.endsWith('/v1/traces') ? endpoint : `${endpoint.replace(/\/$/, '')}/v1/traces`, + }), + }); + + sdk.start(); + return sdk; +} + +export async function shutdownOpenTelemetry(): Promise { + if (sdk) { + await sdk.shutdown(); + sdk = null; + } +} diff --git a/packages/otel/tsconfig.json b/packages/otel/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/otel/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/permissions/package.json b/packages/permissions/package.json new file mode 100644 index 0000000..10a4f1b --- /dev/null +++ b/packages/permissions/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hexahost/permissions", + "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:*", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/permissions/src/index.ts b/packages/permissions/src/index.ts new file mode 100644 index 0000000..e4c39e1 --- /dev/null +++ b/packages/permissions/src/index.ts @@ -0,0 +1,134 @@ +export type ServerPermission = + | 'server.view' + | 'server.start' + | 'server.stop' + | 'server.restart' + | 'server.kill' + | 'server.console.view' + | 'server.console.command' + | 'server.files.view' + | 'server.files.edit' + | 'server.files.upload' + | 'server.files.download' + | 'server.files.delete' + | 'server.software.change' + | 'server.addons.manage' + | 'server.worlds.manage' + | 'server.players.manage' + | 'server.backups.view' + | 'server.backups.create' + | 'server.backups.restore' + | 'server.backups.delete' + | 'server.schedules.manage' + | 'server.members.manage' + | 'server.billing.view' + | 'server.delete'; + +export type ServerMemberRole = + | 'OWNER' + | 'ADMIN' + | 'OPERATOR' + | 'DEVELOPER' + | 'VIEWER'; + +const ROLE_PERMISSIONS: Record = { + OWNER: [ + 'server.view', + 'server.start', + 'server.stop', + 'server.restart', + 'server.kill', + 'server.console.view', + 'server.console.command', + 'server.files.view', + 'server.files.edit', + 'server.files.upload', + 'server.files.download', + 'server.files.delete', + 'server.software.change', + 'server.addons.manage', + 'server.worlds.manage', + 'server.players.manage', + 'server.backups.view', + 'server.backups.create', + 'server.backups.restore', + 'server.backups.delete', + 'server.schedules.manage', + 'server.members.manage', + 'server.billing.view', + 'server.delete', + ], + ADMIN: [ + 'server.view', + 'server.start', + 'server.stop', + 'server.restart', + 'server.kill', + 'server.console.view', + 'server.console.command', + 'server.files.view', + 'server.files.edit', + 'server.files.upload', + 'server.files.download', + 'server.files.delete', + 'server.software.change', + 'server.addons.manage', + 'server.worlds.manage', + 'server.players.manage', + 'server.backups.view', + 'server.backups.create', + 'server.backups.restore', + 'server.members.manage', + 'server.billing.view', + ], + OPERATOR: [ + 'server.view', + 'server.start', + 'server.stop', + 'server.restart', + 'server.console.view', + 'server.console.command', + 'server.players.manage', + 'server.backups.view', + ], + DEVELOPER: [ + 'server.view', + 'server.console.view', + 'server.console.command', + 'server.files.view', + 'server.files.edit', + 'server.files.upload', + 'server.files.download', + 'server.addons.manage', + 'server.worlds.manage', + 'server.backups.view', + 'server.backups.create', + ], + VIEWER: ['server.view', 'server.console.view', 'server.files.view', 'server.backups.view'], +}; + +export function permissionsForRole(role: ServerMemberRole): ServerPermission[] { + return ROLE_PERMISSIONS[role]; +} + +export function hasPermission( + role: ServerMemberRole, + permission: ServerPermission, + overrides?: ServerPermission[] | null, +): boolean { + if (overrides?.includes(permission)) { + return true; + } + + return ROLE_PERMISSIONS[role].includes(permission); +} + +export function assertPermission( + role: ServerMemberRole, + permission: ServerPermission, + overrides?: ServerPermission[] | null, +): void { + if (!hasPermission(role, permission, overrides)) { + throw new Error(`Missing permission: ${permission}`); + } +} diff --git a/packages/permissions/src/permissions.test.ts b/packages/permissions/src/permissions.test.ts new file mode 100644 index 0000000..1a70111 --- /dev/null +++ b/packages/permissions/src/permissions.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { hasPermission, permissionsForRole } from './index'; + +describe('permissions', () => { + it('grants owner full access', () => { + expect(hasPermission('OWNER', 'server.delete')).toBe(true); + expect(permissionsForRole('OWNER')).toContain('server.kill'); + }); + + it('restricts viewer to read-only', () => { + expect(hasPermission('VIEWER', 'server.start')).toBe(false); + expect(hasPermission('VIEWER', 'server.view')).toBe(true); + }); + + it('honors explicit overrides', () => { + expect(hasPermission('VIEWER', 'server.start', ['server.start'])).toBe(true); + }); +}); diff --git a/packages/permissions/tsconfig.json b/packages/permissions/tsconfig.json new file mode 100644 index 0000000..a1b090e --- /dev/null +++ b/packages/permissions/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@hexahost/typescript-config/library.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/server-state/package.json b/packages/server-state/package.json new file mode 100644 index 0000000..55d187b --- /dev/null +++ b/packages/server-state/package.json @@ -0,0 +1,17 @@ +{ + "name": "@hexahost/server-state", + "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:*", + "typescript": "^5.7.3", + "vitest": "^3.0.5" + } +} diff --git a/packages/server-state/src/index.ts b/packages/server-state/src/index.ts new file mode 100644 index 0000000..9db5e36 --- /dev/null +++ b/packages/server-state/src/index.ts @@ -0,0 +1,107 @@ +export type GameServerStatus = + | 'DRAFT' + | 'ALLOCATING' + | 'PROVISIONING' + | 'INSTALLING' + | 'STOPPED' + | 'QUEUED' + | 'STARTING' + | 'RUNNING' + | 'STOPPING' + | 'BACKING_UP' + | 'RESTORING' + | 'MIGRATING' + | 'SUSPENDED' + | 'MAINTENANCE' + | 'UNKNOWN' + | 'ERROR' + | 'DELETING' + | 'DELETED'; + +export const ALLOWED_TRANSITIONS: Record = { + DRAFT: ['ALLOCATING', 'PROVISIONING', 'DELETING'], + ALLOCATING: ['PROVISIONING', 'ERROR', 'DELETING'], + PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'], + INSTALLING: ['STOPPED', 'ERROR', 'DELETING'], + STOPPED: [ + 'STARTING', + 'QUEUED', + 'PROVISIONING', + 'BACKING_UP', + 'RESTORING', + 'MIGRATING', + 'SUSPENDED', + 'MAINTENANCE', + 'DELETING', + ], + QUEUED: ['STARTING', 'STOPPED', 'DELETING'], + STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], + RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'UNKNOWN', 'DELETING'], + STOPPING: ['STOPPED', 'ERROR', 'UNKNOWN', 'DELETING'], + BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'], + RESTORING: ['STOPPED', 'ERROR'], + MIGRATING: ['STOPPED', 'ERROR'], + SUSPENDED: ['STOPPED', 'DELETING'], + MAINTENANCE: ['STOPPED', 'DELETING'], + UNKNOWN: ['STOPPED', 'STARTING', 'ERROR', 'DELETING'], + ERROR: ['STOPPED', 'STARTING', 'QUEUED', 'PROVISIONING', 'DELETING'], + DELETING: ['DELETED'], + DELETED: [], +}; + +export const IN_PROGRESS_STATUSES: GameServerStatus[] = [ + 'ALLOCATING', + 'PROVISIONING', + 'INSTALLING', + 'STARTING', + 'STOPPING', + 'BACKING_UP', + 'RESTORING', + 'MIGRATING', + 'DELETING', +]; + +export function assertTransition( + fromStatus: GameServerStatus, + toStatus: GameServerStatus, +): void { + const allowed = ALLOWED_TRANSITIONS[fromStatus]; + + if (!allowed.includes(toStatus)) { + throw new Error(`Invalid state transition from ${fromStatus} to ${toStatus}`); + } +} + +export function isInProgress(status: GameServerStatus): boolean { + return IN_PROGRESS_STATUSES.includes(status); +} + +export function resolveStartTarget(status: GameServerStatus): GameServerStatus { + switch (status) { + case 'DRAFT': + case 'ERROR': + return 'PROVISIONING'; + case 'STOPPED': + case 'UNKNOWN': + case 'QUEUED': + return 'STARTING'; + default: + throw new Error(`Cannot start server while in status ${status}`); + } +} + +export function resolveStopTarget(status: GameServerStatus): GameServerStatus { + if (status !== 'RUNNING') { + throw new Error(`Cannot stop server while in status ${status}`); + } + + return 'STOPPING'; +} + +export function resolveKillTarget(status: GameServerStatus): GameServerStatus { + if (status !== 'RUNNING' && status !== 'STARTING' && status !== 'STOPPING') { + throw new Error(`Cannot kill server while in status ${status}`); + } + + return 'STOPPED'; +} diff --git a/packages/server-state/src/state.test.ts b/packages/server-state/src/state.test.ts new file mode 100644 index 0000000..303949a --- /dev/null +++ b/packages/server-state/src/state.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { + assertTransition, + isInProgress, + resolveKillTarget, + resolveStartTarget, +} from './index'; + +describe('server state machine', () => { + it('allows draft to provisioning', () => { + expect(() => assertTransition('DRAFT', 'PROVISIONING')).not.toThrow(); + }); + + it('rejects invalid transition', () => { + expect(() => assertTransition('DELETED', 'RUNNING')).toThrow(); + }); + + it('detects in-progress statuses', () => { + expect(isInProgress('STARTING')).toBe(true); + expect(isInProgress('STOPPED')).toBe(false); + }); + + it('resolves start and kill targets', () => { + expect(resolveStartTarget('STOPPED')).toBe('STARTING'); + expect(resolveKillTarget('RUNNING')).toBe('STOPPED'); + }); +}); diff --git a/packages/server-state/tsconfig.json b/packages/server-state/tsconfig.json new file mode 100644 index 0000000..a1b090e --- /dev/null +++ b/packages/server-state/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@hexahost/typescript-config/library.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/ui/package.json b/packages/ui/package.json index c2484c3..e46401c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,6 +12,10 @@ "typecheck": "tsc --noEmit", "lint": "tsc --noEmit" }, + "dependencies": { + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6" + }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" diff --git a/packages/ui/src/components/Dialog.tsx b/packages/ui/src/components/Dialog.tsx new file mode 100644 index 0000000..885ea1c --- /dev/null +++ b/packages/ui/src/components/Dialog.tsx @@ -0,0 +1,112 @@ +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { forwardRef, type ComponentPropsWithoutRef, type ElementRef, type HTMLAttributes } from "react"; +import { cn } from "../lib/cn"; + +export const Dialog = DialogPrimitive.Root; +export const DialogTrigger = DialogPrimitive.Trigger; +export const DialogPortal = DialogPrimitive.Portal; +export const DialogClose = DialogPrimitive.Close; + +export const DialogOverlay = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +export const DialogContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + + + +)); + +DialogContent.displayName = DialogPrimitive.Content.displayName; + +export interface DialogHeaderProps extends HTMLAttributes {} + +export const DialogHeader = forwardRef( + ({ className, ...props }, ref) => ( +
    + ), +); + +DialogHeader.displayName = "DialogHeader"; + +export interface DialogFooterProps extends HTMLAttributes {} + +export const DialogFooter = forwardRef( + ({ className, ...props }, ref) => ( +
    + ), +); + +DialogFooter.displayName = "DialogFooter"; + +export const DialogTitle = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +export const DialogDescription = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DialogDescription.displayName = DialogPrimitive.Description.displayName; diff --git a/packages/ui/src/components/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu.tsx new file mode 100644 index 0000000..546d84f --- /dev/null +++ b/packages/ui/src/components/DropdownMenu.tsx @@ -0,0 +1,199 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { forwardRef, type ComponentPropsWithoutRef, type ElementRef, type HTMLAttributes } from "react"; +import { cn } from "../lib/cn"; + +export const DropdownMenu = DropdownMenuPrimitive.Root; +export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +export const DropdownMenuGroup = DropdownMenuPrimitive.Group; +export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +export const DropdownMenuSub = DropdownMenuPrimitive.Sub; +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +export const DropdownMenuSubTrigger = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); + +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +export const DropdownMenuSubContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +export const DropdownMenuContent = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); + +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +export const DropdownMenuItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); + +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +export const DropdownMenuCheckboxItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); + +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +export const DropdownMenuRadioItem = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + {children} + +)); + +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +export interface DropdownMenuLabelProps + extends ComponentPropsWithoutRef { + inset?: boolean; +} + +export const DropdownMenuLabel = forwardRef< + ElementRef, + DropdownMenuLabelProps +>(({ className, inset, ...props }, ref) => ( + +)); + +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +export const DropdownMenuSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +export interface DropdownMenuShortcutProps extends HTMLAttributes {} + +export const DropdownMenuShortcut = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 2730365..7de7ef9 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,4 +13,37 @@ export { type CardProps, type CardTitleProps, } from "./components/Card"; +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, + type DialogFooterProps, + type DialogHeaderProps, +} from "./components/Dialog"; +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, + type DropdownMenuLabelProps, + type DropdownMenuShortcutProps, +} from "./components/DropdownMenu"; export { cn } from "./lib/cn"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b5b0776..63d43c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: apps/api: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.750.0 + version: 3.1075.0 '@fastify/cookie': specifier: ^11.0.2 version: 11.0.2 @@ -44,15 +47,27 @@ importers: '@hexahost/dns': specifier: workspace:* version: link:../../packages/dns + '@hexahost/feature-flags': + specifier: workspace:* + version: link:../../packages/feature-flags '@hexahost/integration-auth': specifier: workspace:* version: link:../../packages/integration-auth '@hexahost/metering': specifier: workspace:* version: link:../../packages/metering + '@hexahost/otel': + specifier: workspace:* + version: link:../../packages/otel + '@hexahost/permissions': + specifier: workspace:* + version: link:../../packages/permissions '@hexahost/scheduler': specifier: workspace:* version: link:../../packages/scheduler + '@hexahost/server-state': + specifier: workspace:* + version: link:../../packages/server-state '@hexahost/storage': specifier: workspace:* version: link:../../packages/storage @@ -74,6 +89,9 @@ importers: bullmq: specifier: ^5.34.8 version: 5.79.1 + cron-parser: + specifier: ^5.0.4 + version: 5.6.1 fastify: specifier: ^5.2.1 version: 5.8.5 @@ -124,6 +142,27 @@ importers: specifier: ^5.7.3 version: 5.9.3 + apps/docs: + devDependencies: + serve: + specifier: ^14.2.4 + version: 14.2.6 + + apps/e2e: + devDependencies: + '@hexahost/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@playwright/test': + specifier: ^1.51.1 + version: 1.61.1 + '@types/node': + specifier: ^22.13.10 + version: 22.20.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + apps/web: dependencies: '@hexahost/ui': @@ -137,10 +176,10 @@ importers: version: 5.101.1(react@19.2.7) next: specifier: ^15.2.3 - version: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-intl: specifier: ^4.0.2 - version: 4.13.0(next@15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3) + version: 4.13.0(next@15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -187,6 +226,9 @@ importers: apps/worker: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.758.0 + version: 3.1075.0 '@hexahost/catalog': specifier: workspace:* version: link:../../packages/catalog @@ -199,18 +241,27 @@ importers: '@hexahost/dns': specifier: workspace:* version: link:../../packages/dns + '@hexahost/email-templates': + specifier: workspace:* + version: link:../../packages/email-templates '@hexahost/metering': specifier: workspace:* version: link:../../packages/metering '@hexahost/scheduler': specifier: workspace:* version: link:../../packages/scheduler + '@hexahost/server-state': + specifier: workspace:* + version: link:../../packages/server-state '@hexahost/storage': specifier: workspace:* version: link:../../packages/storage bullmq: specifier: ^5.34.10 version: 5.79.1 + cron-parser: + specifier: ^5.0.4 + version: 5.6.1 ioredis: specifier: ^5.4.2 version: 5.10.1 @@ -356,8 +407,27 @@ importers: 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/email-templates: + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + packages/eslint-config: {} + packages/feature-flags: + dependencies: + '@hexahost/database': + specifier: workspace:* + version: link:../database + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/integration-auth: devDependencies: '@hexahost/typescript-config': @@ -388,6 +458,40 @@ importers: 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/otel: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/exporter-trace-otlp-http': + specifier: ^0.57.1 + version: 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^1.30.1 + version: 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: ^0.57.1 + version: 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': + specifier: ^1.30.0 + version: 1.41.1 + devDependencies: + typescript: + specifier: ^5.7.3 + version: 5.9.3 + + packages/permissions: + devDependencies: + '@hexahost/typescript-config': + specifier: workspace:* + version: link:../typescript-config + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/scheduler: devDependencies: '@hexahost/typescript-config': @@ -403,6 +507,18 @@ importers: 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/server-state: + devDependencies: + '@hexahost/typescript-config': + specifier: workspace:* + version: link:../typescript-config + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4) + packages/storage: dependencies: '@aws-sdk/client-s3': @@ -432,6 +548,12 @@ importers: packages/ui: dependencies: + '@radix-ui/react-dialog': + specifier: ^1.1.6 + version: 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.6 + version: 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: ^18.0.0 || ^19.0.0 version: 19.2.7 @@ -853,6 +975,21 @@ packages: '@fastify/websocket@11.2.0': resolution: {integrity: sha512-3HrDPbAG1CzUCqnslgJxppvzaAZffieOVbLp1DAy1huCSynUWPifSvfdEDUR8HlJLp3sp1A36uOM2tJogADS8w==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@formatjs/fast-memoize@3.1.6': resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==} @@ -865,6 +1002,15 @@ packages: '@formatjs/intl-localematcher@0.8.10': resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==} + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@hookform/resolvers@4.1.3': resolution: {integrity: sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==} peerDependencies: @@ -1193,6 +1339,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -1503,6 +1652,172 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/context-async-hooks@1.30.1': + resolution: {integrity: sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.57.2': + resolution: {integrity: sha512-eovEy10n3umjKJl2Ey6TLzikPE+W4cUQ4gCwgGP1RqzTGtgDra0WjIqdy29ohiUKfvmbiL3MndZww58xfIvyFw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.57.2': + resolution: {integrity: sha512-0rygmvLcehBRp56NQVLSleJ5ITTduq/QfU7obOkyWgPpFHulwpw2LYTqNIz5TczKZuy5YY+5D3SDnXZL1tXImg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.57.2': + resolution: {integrity: sha512-ta0ithCin0F8lu9eOf4lEz9YAScecezCHkMMyDkvd9S7AnZNX5ikUmC5EQOQADU+oCcgo/qkQIaKcZvQ0TYKDw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2': + resolution: {integrity: sha512-r70B8yKR41F0EC443b5CGB4rUaOMm99I5N75QQt6sHKxYDzSEc6gm48Diz1CI1biwa5tDPznpylTrywO/pT7qw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.57.2': + resolution: {integrity: sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.57.2': + resolution: {integrity: sha512-HX068Q2eNs38uf7RIkNN9Hl4Ynl+3lP0++KELkXMCpsCbFO03+0XNNZ1SkwxPlP9jrhQahsMPMkzNXpq3fKsnw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.57.2': + resolution: {integrity: sha512-VqIqXnuxWMWE/1NatAGtB1PvsQipwxDcdG4RwA/umdBcW3/iOHp0uejvFHTRN2O78ZPged87ErJajyUBPUhlDQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2': + resolution: {integrity: sha512-gHU1vA3JnHbNxEXg5iysqCWxN9j83d7/epTYBZflqQnTyCC4N7yZXn/dMM+bEmyhQPGjhCkNZLx4vZuChH1PYw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.57.2': + resolution: {integrity: sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.57.2': + resolution: {integrity: sha512-awDdNRMIwDvUtoRYxRhja5QYH6+McBLtoz1q9BeEsskhZcrGmH/V1fWpGx8n+Rc+542e8pJA6y+aullbIzQmlw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@1.30.1': + resolution: {integrity: sha512-6S2QIMJahIquvFaaxmcwpvQQRD/YFaMTNoIxrfPIPOeITN+a8lfEcPDxNxn8JDAaxkg+4EnXhz8upVDYenoQjA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation@0.57.2': + resolution: {integrity: sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.57.2': + resolution: {integrity: sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2': + resolution: {integrity: sha512-USn173KTWy0saqqRB5yU9xUZ2xdgb1Rdu5IosJnm9aV4hMTuFFRTUsQxbgc24QxpCHeoKzzCSnS/JzdV0oM2iQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.57.2': + resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@1.30.1': + resolution: {integrity: sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@1.30.1': + resolution: {integrity: sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.57.2': + resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@1.30.1': + resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-node@0.57.2': + resolution: {integrity: sha512-8BaeqZyN5sTuPBtAoY+UtKwXBdqyuRKmekN5bFzAO40CgbGzAxfTpiL3PBerT7rhZ7p2nBdq7FaMv/tBQgHE4A==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@1.30.1': + resolution: {integrity: sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@otplib/core@12.0.1': resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} @@ -1606,6 +1921,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@prisma/client@6.19.3': resolution: {integrity: sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==} engines: {node: '>=18.18'} @@ -1636,6 +1956,303 @@ packages: '@prisma/get-platform@6.19.3': resolution: {integrity: sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + + '@radix-ui/primitive@1.1.4': + resolution: {integrity: sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==} + + '@radix-ui/react-arrow@1.1.11': + resolution: {integrity: sha512-Kdil9BB1rIFC/khmf4hC35bn8701AJcizTU7G7cUbEbk5XqqbjDuHW60uUfKqO5WojjZcbAW51Q7P0hRmMLw8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.11': + resolution: {integrity: sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.3': + resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.4': + resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.18': + resolution: {integrity: sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.2': + resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.14': + resolution: {integrity: sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.19': + resolution: {integrity: sha512-HZccBkbK0LOi8nYKIp5jll/zIRW0cCOmG6WWyqsSpmXCU+ZlcBbTqIwlBvPCu886C5RVu6c/kHV7xSP8IgYNHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.4': + resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.11': + resolution: {integrity: sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.2': + resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.19': + resolution: {integrity: sha512-Mht9BVd1AIsNFVQr4KG3bIK7XQn5IXF0TL/2ObsrzOdc1loaly/+kBDL5roSCYn8j8XZkvpOD0WYLz2FQtH1Eg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.2': + resolution: {integrity: sha512-3QXNeMkdshed1MR3LNoiCirBywRFPkD8ETJa/HlPuLwSajaQixf2ro+isoDNJlGABg9ug41XuZpINZJIle4XWg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.13': + resolution: {integrity: sha512-z3oXfmaHLJTF1wktbjgD6cn9jiEbq3WSondB10LIuIt2m2Ym4iJlrW04/euMwENDdWDdE7z+OuY7Qyp1YpRSwA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.6': + resolution: {integrity: sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.7': + resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.14': + resolution: {integrity: sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.0': + resolution: {integrity: sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.2': + resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.3': + resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.3': + resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.2': + resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.2': + resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.2': + resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/rect@1.1.2': + resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -1988,6 +2605,9 @@ packages: '@types/react@19.2.17': resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/shimmer@1.2.0': + resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2240,9 +2860,17 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zeit/schemas@2.36.0': + resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -2294,6 +2922,9 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2302,10 +2933,18 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -2317,12 +2956,19 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2424,6 +3070,10 @@ packages: bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + brace-expansion@1.1.15: resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} @@ -2455,6 +3105,14 @@ packages: redis: optional: true + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + c12@3.1.0: resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} peerDependencies: @@ -2487,6 +3145,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} @@ -2494,10 +3156,18 @@ packages: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chardet@2.2.0: resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} @@ -2523,6 +3193,13 @@ packages: citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -2542,6 +3219,14 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clone@1.0.4: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} @@ -2571,6 +3256,14 @@ packages: resolution: {integrity: sha512-uiqLcOiVDJtBP8WGkZHEP+FZIhTzP1dxvn59EfoYUi9gqupjrBWVQkO2atDrbnKPwLeotFYDsuNb26uBMqB+hw==} engines: {node: '>= 6'} + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -2581,6 +3274,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -2602,6 +3299,10 @@ packages: resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} engines: {node: '>=12.0.0'} + cron-parser@5.6.1: + resolution: {integrity: sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2632,6 +3333,14 @@ packages: dateformat@4.6.3: resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2653,6 +3362,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2697,6 +3410,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -2718,6 +3434,9 @@ packages: duplexify@4.1.3: resolution: {integrity: sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} @@ -2942,6 +3661,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -3061,6 +3784,11 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3088,10 +3816,18 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -3173,6 +3909,10 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3195,6 +3935,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@1.15.0: + resolution: {integrity: sha512-bpQy+CrsRmYmoPMAE/0G33iwRqwW4ouqdRg8jgbH3aKuCtOc8lxgmYXg2dMM92CRiGP660EtBcymH/eVUpCSaA==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3202,6 +3945,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -3259,6 +4005,11 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-document.all@1.0.0: resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} engines: {node: '>= 0.4'} @@ -3303,6 +4054,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -3315,6 +4070,10 @@ packages: resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} engines: {node: '>= 0.4'} + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + is-string@1.1.1: resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} engines: {node: '>= 0.4'} @@ -3343,6 +4102,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -3464,6 +4227,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -3480,6 +4246,9 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -3517,10 +4286,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -3544,6 +4321,12 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3574,6 +4357,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3662,6 +4449,10 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + nypm@0.6.7: resolution: {integrity: sha512-s3ds97SD5pd1dULE+tHUk1DrV0cSHOnsfpcdGATJ8JpBo21DoKqN9exTH4/2nhPQNOLomBdTFMicN94S4DrZrQ==} engines: {node: '>=18'} @@ -3710,6 +4501,10 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -3755,6 +4550,9 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -3766,6 +4564,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -3825,6 +4626,16 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -3910,6 +4721,10 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -3926,9 +4741,17 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -3943,6 +4766,36 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.7: resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} engines: {node: '>=0.10.0'} @@ -3985,10 +4838,25 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + + registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@7.5.2: + resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} + engines: {node: '>=8.6.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4089,6 +4957,14 @@ packages: engines: {node: '>=10'} hasBin: true + serve-handler@6.1.7: + resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} + + serve@14.2.6: + resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==} + engines: {node: '>= 14'} + hasBin: true + set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} @@ -4119,6 +4995,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shimmer@1.2.1: + resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -4194,6 +5073,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string.prototype.includes@2.0.1: resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} engines: {node: '>= 0.4'} @@ -4224,10 +5107,22 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4429,6 +5324,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4478,17 +5377,44 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-intl@4.13.0: resolution: {integrity: sha512-fAFDrWaASxlhXOipcOyb5VDD+YONqj6+8O8EcG/J7RBoOUF3A8YahRWLN+mBxYMrlMQB8N6Voqk5X+YC+HSL0A==} peerDependencies: react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0 + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4613,6 +5539,10 @@ packages: engines: {node: '>=8'} hasBin: true + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4621,6 +5551,14 @@ packages: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4636,10 +5574,18 @@ packages: utf-8-validate: optional: true + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5171,6 +6117,23 @@ snapshots: - bufferutil - utf-8-validate + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/utils@0.2.11': {} + '@formatjs/fast-memoize@3.1.6': {} '@formatjs/icu-messageformat-parser@3.5.12': @@ -5183,6 +6146,18 @@ snapshots: dependencies: '@formatjs/fast-memoize': 3.1.6 + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.3 + '@hookform/resolvers@4.1.3(react-hook-form@7.80.0(react@19.2.7))': dependencies: '@standard-schema/utils': 0.3.0 @@ -5464,6 +6439,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@lukeed/csprng@1.1.0': {} '@lukeed/ms@2.0.2': {} @@ -5721,6 +6698,239 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-grpc@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@types/shimmer': 1.2.0 + import-in-the-middle: 1.15.0 + require-in-the-middle: 7.5.2 + semver: 7.8.5 + shimmer: 1.2.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + protobufjs: 7.6.5 + + '@opentelemetry/propagator-b3@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + + '@opentelemetry/sdk-trace-node@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + semver: 7.8.5 + + '@opentelemetry/semantic-conventions@1.28.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@otplib/core@12.0.1': {} '@otplib/plugin-crypto@12.0.1': @@ -5806,6 +7016,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@prisma/client@6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)': optionalDependencies: prisma: 6.19.3(typescript@5.9.3) @@ -5841,6 +7055,280 @@ snapshots: dependencies: '@prisma/debug': 6.19.3 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + + '@radix-ui/primitive@1.1.4': {} + + '@radix-ui/react-arrow@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-menu': 2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.4(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-menu@2.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-dismissable-layer': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-focus-scope': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-popper': 1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-presence': 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-roving-focus': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + aria-hidden: 1.2.6 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-arrow': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.6(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@radix-ui/primitive': 1.1.4 + '@radix-ui/react-collection': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-context': 1.1.4(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-direction': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.0(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7) + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/rect': 1.1.2 + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7) + react: 19.2.7 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/rect@1.1.2': {} + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -6118,6 +7606,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/shimmer@1.2.0': {} + '@types/ws@8.18.1': dependencies: '@types/node': 22.20.0 @@ -6405,8 +7895,14 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zeit/schemas@2.36.0': {} + abstract-logging@2.0.1: {} + acorn-import-attributes@1.9.5(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + acorn-import-phases@1.0.4(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -6459,14 +7955,22 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + ansis@4.2.0: {} any-promise@1.3.0: {} @@ -6476,10 +7980,16 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 + arch@2.2.0: {} + arg@5.0.2: {} argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -6599,6 +8109,17 @@ snapshots: bowser@2.14.1: {} + boxen@7.0.0: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + brace-expansion@1.1.15: dependencies: balanced-match: 1.0.2 @@ -6638,6 +8159,10 @@ snapshots: transitivePeerDependencies: - supports-color + bytes@3.0.0: {} + + bytes@3.1.2: {} + c12@3.1.0: dependencies: chokidar: 4.0.3 @@ -6676,6 +8201,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@7.0.1: {} + caniuse-lite@1.0.30001799: {} chai@5.3.3: @@ -6686,11 +8213,17 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.0.1: {} + chardet@2.2.0: {} check-error@2.1.3: {} @@ -6719,6 +8252,10 @@ snapshots: citty@0.2.2: {} + cjs-module-lexer@1.4.3: {} + + cli-boxes@3.0.0: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -6735,6 +8272,18 @@ snapshots: client-only@0.0.1: {} + clipboardy@3.0.0: + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clone@1.0.4: {} cluster-key-slot@1.1.2: {} @@ -6756,12 +8305,30 @@ snapshots: array-timsort: 1.0.3 esprima: 4.0.1 + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + concat-map@0.0.1: {} confbox@0.2.4: {} consola@3.4.2: {} + content-disposition@0.5.2: {} + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -6781,6 +8348,10 @@ snapshots: dependencies: luxon: 3.7.2 + cron-parser@5.6.1: + dependencies: + luxon: 3.7.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6813,6 +8384,10 @@ snapshots: dateformat@4.6.3: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@3.2.7: dependencies: ms: 2.1.3 @@ -6823,6 +8398,8 @@ snapshots: deep-eql@5.0.2: {} + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -6857,6 +8434,8 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + didyoumean@1.2.2: {} dlv@1.1.3: {} @@ -6880,6 +8459,8 @@ snapshots: readable-stream: 3.6.2 stream-shift: 1.0.3 + eastasianwidth@0.2.0: {} + effect@3.21.0: dependencies: '@standard-schema/spec': 1.1.0 @@ -7269,6 +8850,18 @@ snapshots: events@3.3.0: {} + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + expect-type@1.4.0: {} exsolve@1.1.0: {} @@ -7421,6 +9014,9 @@ snapshots: fs-monkey@1.1.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -7457,11 +9053,15 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -7549,6 +9149,8 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + human-signals@2.1.0: {} + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -7568,10 +9170,19 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@1.15.0: + dependencies: + acorn: 8.17.0 + acorn-import-attributes: 1.9.5(acorn@8.17.0) + cjs-module-lexer: 1.4.3 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} inherits@2.0.4: {} + ini@1.3.8: {} + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 @@ -7649,6 +9260,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-docker@2.2.1: {} + is-document.all@1.0.0: dependencies: call-bound: 1.0.4 @@ -7686,6 +9299,8 @@ snapshots: is-number@7.0.0: {} + is-port-reachable@4.0.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -7699,6 +9314,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-stream@2.0.1: {} + is-string@1.1.1: dependencies: call-bound: 1.0.4 @@ -7727,6 +9344,10 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@2.0.5: {} isexe@2.0.0: {} @@ -7838,6 +9459,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} @@ -7851,6 +9474,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + long@5.3.2: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -7880,8 +9505,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.33.0: {} + mime-db@1.54.0: {} + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + mime@3.0.0: {} mimic-fn@2.1.0: {} @@ -7898,6 +9529,10 @@ snapshots: minipass@7.1.3: {} + module-details-from-path@1.0.4: {} + + ms@2.0.0: {} + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -7930,6 +9565,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.4: {} + negotiator@1.0.0: {} neo-async@2.6.2: {} @@ -7943,14 +9580,14 @@ snapshots: next-intl-swc-plugin-extractor@4.13.0: {} - next-intl@4.13.0(next@15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3): + next-intl@4.13.0(next@15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3): dependencies: '@formatjs/intl-localematcher': 0.8.10 '@parcel/watcher': 2.5.6 '@swc/core': 1.15.43 icu-minify: 4.13.0 negotiator: 1.0.0 - next: 15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) next-intl-swc-plugin-extractor: 4.13.0 po-parser: 2.1.1 react: 19.2.7 @@ -7965,7 +9602,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@15.5.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@15.5.19(@opentelemetry/api@1.9.1)(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 15.5.19 '@swc/helpers': 0.5.15 @@ -7983,6 +9620,8 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.19 '@next/swc-win32-arm64-msvc': 15.5.19 '@next/swc-win32-x64-msvc': 15.5.19 + '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -8016,6 +9655,10 @@ snapshots: normalize-path@3.0.0: {} + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + nypm@0.6.7: dependencies: citty: 0.2.2 @@ -8070,6 +9713,8 @@ snapshots: on-exit-leak-free@2.1.2: {} + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -8134,6 +9779,8 @@ snapshots: path-exists@4.0.0: {} + path-is-inside@1.0.2: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -8143,6 +9790,8 @@ snapshots: lru-cache: 11.5.1 minipass: 7.1.3 + path-to-regexp@3.3.0: {} + path-to-regexp@8.4.2: {} path-type@4.0.0: {} @@ -8216,6 +9865,14 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + pluralize@8.0.0: {} po-parser@2.1.1: {} @@ -8287,6 +9944,20 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 22.20.0 + long: 5.3.2 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8300,11 +9971,20 @@ snapshots: quick-format-unescaped@4.0.4: {} + range-parser@1.2.0: {} + rc9@2.1.2: dependencies: defu: 6.1.7 destr: 2.0.5 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 @@ -8316,6 +9996,33 @@ snapshots: react-is@16.13.1: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + get-nonce: 1.0.1 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + react@19.2.7: {} read-cache@1.0.0: @@ -8364,8 +10071,27 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + registry-auth-token@3.3.2: + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + + registry-url@3.1.0: + dependencies: + rc: 1.2.8 + + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + require-in-the-middle@7.5.2: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -8492,6 +10218,32 @@ snapshots: semver@7.8.5: {} + serve-handler@6.1.7: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.5 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + + serve@14.2.6: + dependencies: + '@zeit/schemas': 2.36.0 + ajv: 8.18.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.8.1 + is-port-reachable: 4.0.0 + serve-handler: 6.1.7 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + set-cookie-parser@2.7.2: {} set-function-length@1.2.2: @@ -8556,6 +10308,8 @@ snapshots: shebang-regex@3.0.0: {} + shimmer@1.2.1: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -8630,6 +10384,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string.prototype.includes@2.0.1: dependencies: call-bind: 1.0.9 @@ -8689,8 +10449,16 @@ snapshots: dependencies: ansi-regex: 5.0.1 + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-json-comments@5.0.3: {} @@ -8873,6 +10641,8 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-fest@2.19.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -8958,10 +10728,22 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + update-check@1.5.4: + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + uri-js@4.4.1: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + use-intl@4.13.0(react@19.2.7): dependencies: '@formatjs/fast-memoize': 3.1.6 @@ -8970,8 +10752,18 @@ snapshots: intl-messageformat: 11.2.9 react: 19.2.7 + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.7 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + util-deprecate@1.0.2: {} + vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4): dependencies: cac: 6.7.14 @@ -9151,6 +10943,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + word-wrap@1.2.5: {} wrap-ansi@6.2.0: @@ -9159,12 +10955,36 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} ws@8.21.0: {} + y18n@5.0.8: {} + yargs-parser@21.1.1: {} + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.3: {}