diff --git a/.prettierignore b/.prettierignore index 18073e1..bcce50d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,7 @@ dist .turbo pnpm-lock.yaml apps/daemon/target + +# Captured bring-up reports, not maintained sources — reflowing them would +# only churn a record of what happened. +conduit-bringup-artifacts diff --git a/INSTALLATION.md b/INSTALLATION.md index a5171b8..1fd125a 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -1,6 +1,7 @@ # Installation Guide This guide covers three deployment methods: + 1. **Development Setup** — for local development 2. **Docker Production** — single-command deployment with Docker Compose 3. **Manual Production** — step-by-step on Ubuntu 22.04+ @@ -10,10 +11,12 @@ This guide covers three deployment methods: ## Prerequisites ### All Methods + - Git - A PostgreSQL 16+ database (or use the included Docker Compose) ### Development + - **Node.js** 20+ ([nodejs.org](https://nodejs.org)) - **pnpm** 9.15+ (`corepack enable && corepack prepare pnpm@9.15.4 --activate`) - **Rust** 1.83+ ([rustup.rs](https://rustup.rs)) @@ -21,6 +24,7 @@ This guide covers three deployment methods: - **Docker** — for running PostgreSQL and Redis locally ### Docker Production + - **Docker** 24+ with Docker Compose v2 - At least **2 GB RAM** and **10 GB disk** for the panel itself - Additional resources for game servers on daemon nodes @@ -77,6 +81,7 @@ All three steps are idempotent, so re-running them after a `git pull` is the normal way to pick up schema and default-game changes. After seeding, you'll have: + - **Admin account**: `admin@gamepanel.local` / `admin123` - **Games**: Minecraft Java & Bedrock, CS2, Terraria, Rust, Satisfactory, FiveM, ARK: Survival Evolved @@ -155,14 +160,14 @@ Then open `http://:80` and sign in with ### 2.2 What gets started -| Service | Port | Description | -|---------|------|-------------| -| `postgres` | internal | PostgreSQL database | -| `redis` | internal | Rate limiting & cache | -| `migrate` | — | Applies the schema + seed, then exits | -| `api` | internal | Fastify REST API | -| `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` | -| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon | +| Service | Port | Description | +| ---------- | -------------------------- | -------------------------------------------------- | +| `postgres` | internal | PostgreSQL database | +| `redis` | internal | Rate limiting & cache | +| `migrate` | — | Applies the schema + seed, then exits | +| `api` | internal | Fastify REST API | +| `web` | `WEB_PORT` (80) | nginx + React SPA, proxies `/api` and `/socket.io` | +| `daemon` | `DAEMON_GRPC_PORT` (50051) | Rust gRPC daemon | Only `web` and `daemon` publish ports. Postgres, Redis and the API stay on the internal Compose network. @@ -174,11 +179,11 @@ The `migrate` service runs on every `docker compose up`; all three of its steps In the panel, create a node with: -| Field | Value | -|-------|-------| -| FQDN | `host.docker.internal` (or the host's IP/hostname) | -| gRPC port | the `DAEMON_GRPC_PORT` from `.env` | -| Daemon token | the `DAEMON_TOKEN` from `.env` | +| Field | Value | +| ------------ | -------------------------------------------------- | +| FQDN | `host.docker.internal` (or the host's IP/hostname) | +| gRPC port | the `DAEMON_GRPC_PORT` from `.env` | +| Daemon token | the `DAEMON_TOKEN` from `.env` | ### 2.4 Where game server files live @@ -550,12 +555,12 @@ but nothing in it is panel-specific. `.github/workflows/ci.yml` pushes four images to this Gitea instance's own container registry on every `v*` tag: -| Image | Contents | -|---|---| -| `gamepanel-api` | Fastify API | +| Image | Contents | +| ------------------- | -------------------------------------------------------------------- | +| `gamepanel-api` | Fastify API | | `gamepanel-migrate` | The API Dockerfile's `migrate` stage, run once before the API starts | -| `gamepanel-web` | SPA + nginx, built with `VITE_API_URL=/api` | -| `gamepanel-daemon` | Rust daemon | +| `gamepanel-web` | SPA + nginx, built with `VITE_API_URL=/api` | +| `gamepanel-daemon` | Rust daemon | Add a `REGISTRY_TOKEN` repository secret with package write scope, then: @@ -578,14 +583,14 @@ the node. If the panel has a file manager, both steps can be done from it. Paste `docker-compose.panel.yml` into the panel's custom-compose screen and set: -| Variable | Example | Notes | -|---|---|---| -| `REGISTRY` | `gits.hibna.com.tr/hibna` | Namespace holding the four images | -| `TAG` | `v0.1.0` | The tag you pushed | -| `HOST_PORT` | `8096` | **Not 80** if the panel's own web server owns it | -| `DB_PASSWORD`, `REDIS_PASSWORD` | `openssl rand -hex 24` | | -| `JWT_SECRET`, `JWT_REFRESH_SECRET` | `openssl rand -hex 64` | | -| `CORS_ORIGIN` | `https://panel.example.com` | Must match the address the browser uses | +| Variable | Example | Notes | +| ---------------------------------- | --------------------------- | ------------------------------------------------ | +| `REGISTRY` | `gits.hibna.com.tr/hibna` | Namespace holding the four images | +| `TAG` | `v0.1.0` | The tag you pushed | +| `HOST_PORT` | `8096` | **Not 80** if the panel's own web server owns it | +| `DB_PASSWORD`, `REDIS_PASSWORD` | `openssl rand -hex 24` | | +| `JWT_SECRET`, `JWT_REFRESH_SECRET` | `openssl rand -hex 64` | | +| `CORS_ORIGIN` | `https://panel.example.com` | Must match the address the browser uses | The published port is called `HOST_PORT` because panels commonly reverse-proxy "the" port of an installation and need to know which one that is when a stack @@ -657,25 +662,30 @@ Then add the node in the panel with the remote machine's FQDN. ## Troubleshooting ### API won't start + - Check `DATABASE_URL` is correct and PostgreSQL is running - Ensure migrations have been applied: `pnpm db:migrate` - Check logs: `journalctl -u gamepanel-api -f` or `docker compose logs api` ### Daemon can't connect + - Verify `api_url` in daemon config points to the API - Check `node_token` matches what's stored in the panel's nodes table - Ensure the daemon's gRPC port (50051) is open ### Web shows blank page + - Build the SPA: `pnpm --filter @source/web build` - Check nginx config: `sudo nginx -t` - Verify API proxy is working: `curl http://localhost:3000/api/health` ### Docker permission denied + - Ensure the daemon user is in the `docker` group: `usermod -aG docker ` - Or run the daemon with appropriate privileges ### protoc not found (daemon build) + - Ubuntu: `sudo apt install protobuf-compiler` - macOS: `brew install protobuf` - Or download from [github.com/protocolbuffers/protobuf/releases](https://github.com/protocolbuffers/protobuf/releases) @@ -718,24 +728,24 @@ sudo systemctl reload nginx ## Environment Variables Reference -| Variable | Default | Description | -|----------|---------|-------------| -| `DATABASE_URL` | — | PostgreSQL connection string | -| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) | -| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) | -| `DB_NAME` | `gamepanel` | Database name (Docker) | -| `DB_PORT` | `5432` | PostgreSQL exposed port | -| `REDIS_URL` | — | Redis connection string | -| `REDIS_PASSWORD` | `gamepanel` | Redis password | -| `PORT` | `3000` | API listen port | -| `HOST` | `0.0.0.0` | API listen host | -| `NODE_ENV` | `development` | Environment mode | -| `JWT_SECRET` | — | **Required.** Access token signing key | -| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key | -| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin | -| `RATE_LIMIT_MAX` | `100` | Max requests per window | -| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) | -| `WEB_PORT` | `80` | Web nginx exposed port | -| `API_PORT` | `3000` | API exposed port (Docker) | -| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path | -| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port | +| Variable | Default | Description | +| ---------------------- | --------------------------- | --------------------------------------- | +| `DATABASE_URL` | — | PostgreSQL connection string | +| `DB_USER` | `gamepanel` | PostgreSQL username (Docker) | +| `DB_PASSWORD` | `gamepanel` | PostgreSQL password (Docker) | +| `DB_NAME` | `gamepanel` | Database name (Docker) | +| `DB_PORT` | `5432` | PostgreSQL exposed port | +| `REDIS_URL` | — | Redis connection string | +| `REDIS_PASSWORD` | `gamepanel` | Redis password | +| `PORT` | `3000` | API listen port | +| `HOST` | `0.0.0.0` | API listen host | +| `NODE_ENV` | `development` | Environment mode | +| `JWT_SECRET` | — | **Required.** Access token signing key | +| `JWT_REFRESH_SECRET` | — | **Required.** Refresh token signing key | +| `CORS_ORIGIN` | `http://localhost:5173` | Allowed CORS origin | +| `RATE_LIMIT_MAX` | `100` | Max requests per window | +| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate limit window (ms) | +| `WEB_PORT` | `80` | Web nginx exposed port | +| `API_PORT` | `3000` | API exposed port (Docker) | +| `DAEMON_CONFIG` | `/etc/gamepanel/config.yml` | Daemon config file path | +| `DAEMON_GRPC_PORT` | `50051` | Daemon gRPC exposed port | diff --git a/README.md b/README.md index ab7f60a..d555d49 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Modern, open-source game server management panel built with a multi-tenant SaaS ## Features ### Core + - **Multi-Tenant Organizations** — Isolated environments with role-based access control (Admin / User + custom JSONB permissions) - **Docker Container Management** — Full lifecycle: create, start, stop, restart, kill, delete - **Multi-Node Architecture** — Distribute game servers across multiple daemon nodes with health monitoring @@ -15,16 +16,19 @@ Modern, open-source game server management panel built with a multi-tenant SaaS - **Server Creation Wizard** — 3-step guided flow: Basic Info, Node & Allocation, Resources ### Game-Specific + - **Config Editor** — Tab-based UI with parsers for `.properties`, `.json`, `.yaml`, and Source Engine `.cfg` formats - **Plugin Management** — Spiget API integration for Minecraft, manual install for other games, toggle/uninstall - **Player Tracking** — Live player list via RCON protocol (Minecraft `list`, CS2 `status`) ### Advanced + - **Scheduled Tasks** — Visual scheduler with interval, daily, weekly, and cron expression support - **Backup System** — Create, restore, lock/unlock, delete backups with CDN storage integration - **Audit Logging** — Track all actions across the panel with user, server, and IP metadata ### Operations + - **Rate Limiting** — Configurable per-window request limits - **Security Headers** — Helmet.js with CSP, XSS protection, content-type sniffing prevention - **Health Checks** — Built-in endpoints for all services @@ -56,18 +60,18 @@ The API acts as a **gateway** between the frontend and daemon nodes. The fronten ## Tech Stack -| Component | Technology | -|-----------|-----------| -| Monorepo | Turborepo + pnpm | -| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui | -| Backend API | Fastify 5 + TypeBox validation | -| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio | -| Database | PostgreSQL 16 + Drizzle ORM | -| Auth | JWT (access + refresh) + Argon2id | -| Realtime | Socket.IO (frontend ↔ API) | -| Panel ↔ Daemon | gRPC with protobuf | -| Containers | Docker | -| CI/CD | GitHub Actions | +| Component | Technology | +| -------------- | ---------------------------------------------- | +| Monorepo | Turborepo + pnpm | +| Frontend | React 19 + Vite 6 + Tailwind CSS 3 + shadcn/ui | +| Backend API | Fastify 5 + TypeBox validation | +| Daemon | Rust + tonic gRPC + bollard (Docker) + tokio | +| Database | PostgreSQL 16 + Drizzle ORM | +| Auth | JWT (access + refresh) + Argon2id | +| Realtime | Socket.IO (frontend ↔ API) | +| Panel ↔ Daemon | gRPC with protobuf | +| Containers | Docker | +| CI/CD | GitHub Actions | --- @@ -150,16 +154,16 @@ source-gamepanel/ ## Supported Games -| Game | Docker Image | Default Port | Config Format | Plugin Support | -|------|-------------|-------------|---------------|---------------| -| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual | -| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual | -| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — | -| Terraria | `ryshe/terraria` | 7777 | keyvalue | — | -| Rust | `didstopia/rust-server` | 28015 | — | — | -| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — | -| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — | -| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — | +| Game | Docker Image | Default Port | Config Format | Plugin Support | +| -------------------------- | ------------------------------- | ------------------------------------------- | ---------------------------------- | ------------------- | +| Minecraft: Java Edition | `itzg/minecraft-server` | 25565 | `.properties`, `.yml`, `.json` | Spiget API + manual | +| Counter-Strike 2 | `cm2network/cs2` | 27015 | Source `.cfg` (keyvalue) | Manual | +| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — | +| Terraria | `ryshe/terraria` | 7777 | keyvalue | — | +| Rust | `didstopia/rust-server` | 28015 | — | — | +| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — | +| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — | +| ARK: Survival Evolved | `hermsi/ark-server` | 7777/udp + 7778/udp + 27015/udp + 27020/tcp | `GameUserSettings.ini`, `Game.ini` | — | Most games only need a database seed entry: the container mount point, the in-game stop command and the shutdown budget are all columns on `games`, so no @@ -171,40 +175,44 @@ daemon change is required for a new image. Games whose process ignores stdin ## API Endpoints ### Auth -| Method | Path | Description | -|--------|------|-------------| -| POST | `/api/auth/register` | Create account | -| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) | -| POST | `/api/auth/refresh` | Refresh access token | -| POST | `/api/auth/logout` | Invalidate session | -| GET | `/api/auth/me` | Current user profile | + +| Method | Path | Description | +| ------ | -------------------- | ------------------------------------ | +| POST | `/api/auth/register` | Create account | +| POST | `/api/auth/login` | Login (returns JWT + refresh cookie) | +| POST | `/api/auth/refresh` | Refresh access token | +| POST | `/api/auth/logout` | Invalidate session | +| GET | `/api/auth/me` | Current user profile | ### Organizations -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/organizations` | List user's orgs | -| POST | `/api/organizations` | Create org | -| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD | -| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management | + +| Method | Path | Description | +| ---------------- | ----------------------------------- | ----------------- | +| GET | `/api/organizations` | List user's orgs | +| POST | `/api/organizations` | Create org | +| GET/PATCH/DELETE | `/api/organizations/:orgId` | Org CRUD | +| GET/POST/DELETE | `/api/organizations/:orgId/members` | Member management | ### Servers -| Method | Path | Description | -|--------|------|-------------| -| GET/POST | `.../servers` | List / create | -| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD | -| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) | -| GET/PUT | `.../servers/:serverId/config` | Config read/write | -| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management | -| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management | -| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup | -| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks | + +| Method | Path | Description | +| --------------------- | ------------------------------------------- | --------------------------------------- | +| GET/POST | `.../servers` | List / create | +| GET/PATCH/DELETE | `.../servers/:serverId` | Server CRUD | +| POST | `.../servers/:serverId/power` | Power actions (start/stop/restart/kill) | +| GET/PUT | `.../servers/:serverId/config` | Config read/write | +| GET/POST/DELETE | `.../servers/:serverId/plugins` | Plugin management | +| GET/POST/DELETE | `.../servers/:serverId/backups` | Backup management | +| POST | `.../servers/:serverId/backups/:id/restore` | Restore backup | +| GET/POST/PATCH/DELETE | `.../servers/:serverId/schedules` | Scheduled tasks | ### Admin (Super Admin only) -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/admin/users` | All users | -| GET/POST | `/api/admin/games` | Game management | -| GET | `/api/admin/audit-logs` | Audit trail | + +| Method | Path | Description | +| -------- | ----------------------- | --------------- | +| GET | `/api/admin/users` | All users | +| GET/POST | `/api/admin/games` | Game management | +| GET | `/api/admin/audit-logs` | Audit trail | --- diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7a7ac25..50bac2c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -18,10 +18,7 @@ import { AppError } from './lib/errors.js'; const app = Fastify({ logger: { - transport: - process.env.NODE_ENV !== 'production' - ? { target: 'pino-pretty' } - : undefined, + transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, }, }); @@ -46,39 +43,44 @@ await app.register(authPlugin); await app.register(socketPlugin); // Error handler -app.setErrorHandler((error: Error & { validation?: unknown; statusCode?: number; code?: string }, _request, reply) => { - if (error instanceof AppError) { - return reply.code(error.statusCode).send({ - error: error.name, - message: error.message, - code: error.code, - }); - } +app.setErrorHandler( + ( + error: Error & { validation?: unknown; statusCode?: number; code?: string }, + _request, + reply, + ) => { + if (error instanceof AppError) { + return reply.code(error.statusCode).send({ + error: error.name, + message: error.message, + code: error.code, + }); + } - // Fastify validation errors - if (error.validation) { - return reply.code(400).send({ - error: 'Validation Error', - message: error.message, - }); - } + // Fastify validation errors + if (error.validation) { + return reply.code(400).send({ + error: 'Validation Error', + message: error.message, + }); + } - // Rate limit errors - if (error.statusCode === 429) { - return reply.code(429).send({ - error: 'Too Many Requests', - message: 'Rate limit exceeded, please try again later', - }); - } + // Rate limit errors + if (error.statusCode === 429) { + return reply.code(429).send({ + error: 'Too Many Requests', + message: 'Rate limit exceeded, please try again later', + }); + } - app.log.error(error); - return reply.code(error.statusCode ?? 500).send({ - error: 'Internal Server Error', - message: process.env.NODE_ENV === 'production' - ? 'An unexpected error occurred' - : error.message, - }); -}); + app.log.error(error); + return reply.code(error.statusCode ?? 500).send({ + error: 'Internal Server Error', + message: + process.env.NODE_ENV === 'production' ? 'An unexpected error occurred' : error.message, + }); + }, +); // Routes app.get('/api/health', async () => { diff --git a/apps/api/src/lib/cdn.ts b/apps/api/src/lib/cdn.ts index 5c2f220..e8731f6 100644 --- a/apps/api/src/lib/cdn.ts +++ b/apps/api/src/lib/cdn.ts @@ -23,7 +23,9 @@ function getCdnConfig(): { baseUrl: string; apiKey: string } | null { } function getArtifactAccessTtlSeconds(): number { - const raw = Number(process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS); + const raw = Number( + process.env.CDN_PLUGIN_ARTIFACT_TTL_SECONDS ?? DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS, + ); if (!Number.isFinite(raw) || raw <= 0) return DEFAULT_ARTIFACT_ACCESS_TTL_SECONDS; return Math.floor(raw); } @@ -100,11 +102,7 @@ export async function ensurePrivatePluginBucket(): Promise { } } - throw toCdnAppError( - error, - 'Failed to fetch CDN plugin bucket', - 'CDN_BUCKET_READ_FAILED', - ); + throw toCdnAppError(error, 'Failed to fetch CDN plugin bucket', 'CDN_BUCKET_READ_FAILED'); } } @@ -192,10 +190,6 @@ export async function resolveArtifactDownloadUrl(artifactUrl: string): Promise { - const stopTimeoutSeconds = Number(options.stopTimeoutSeconds) > 0 - ? Math.floor(Number(options.stopTimeoutSeconds)) - : 0; + const stopTimeoutSeconds = + Number(options.stopTimeoutSeconds) > 0 ? Math.floor(Number(options.stopTimeoutSeconds)) : 0; // The daemon waits out the shutdown before replying, so the RPC deadline has // to outlive the game's own budget (ARK saves its world for minutes). const rpcTimeoutMs = action === 'stop' || action === 'restart' - ? Math.min(Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), MAX_POWER_RPC_TIMEOUT_MS) + ? Math.min( + Math.max((stopTimeoutSeconds + 20) * 1_000, POWER_RPC_TIMEOUT_MS), + MAX_POWER_RPC_TIMEOUT_MS, + ) : POWER_RPC_TIMEOUT_MS; const client = createClient(node); diff --git a/apps/api/src/lib/managed-config.ts b/apps/api/src/lib/managed-config.ts index cc7ef7d..3687f6d 100644 --- a/apps/api/src/lib/managed-config.ts +++ b/apps/api/src/lib/managed-config.ts @@ -1,9 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { - daemonReadFile, - daemonWriteFile, - type DaemonNodeConnection, -} from './daemon.js'; +import { daemonReadFile, daemonWriteFile, type DaemonNodeConnection } from './daemon.js'; /** * Some game images run a SteamCMD `app_update ... validate` on every container @@ -168,21 +164,14 @@ export function managedConfigFilesForGame(gameSlug: string): ManagedConfigFile[] } /** The managed file a request path refers to, or `null` if it is not managed. */ -export function managedConfigFileFor( - gameSlug: string, - path: string, -): ManagedConfigFile | null { +export function managedConfigFileFor(gameSlug: string, path: string): ManagedConfigFile | null { const normalized = normalizePath(path); - return ( - managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null - ); + return managedConfigFilesForGame(gameSlug).find((file) => file.path === normalized) ?? null; } export function isManagedConfigShadowFile(gameSlug: string, fileName: string): boolean { const normalized = fileName.trim(); - return managedConfigFilesForGame(gameSlug).some( - (file) => file.shadowFileName === normalized, - ); + return managedConfigFilesForGame(gameSlug).some((file) => file.shadowFileName === normalized); } /** @@ -371,10 +360,7 @@ export function sustainManagedConfigsAfterStart( stableRounds = drifted ? 0 : stableRounds + 1; - if ( - stableRounds >= REQUIRED_STABLE_ROUNDS && - Date.now() - startedAt >= MIN_WATCH_MS - ) { + if (stableRounds >= REQUIRED_STABLE_ROUNDS && Date.now() - startedAt >= MIN_WATCH_MS) { return; } } diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index 857a472..b55e82d 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -45,7 +45,10 @@ export async function getOrgMembership( * Check if the user has a specific permission in the organization. * Super admins always have all permissions. */ -export function hasPermission(membership: OrgMember | 'super_admin', permission: Permission): boolean { +export function hasPermission( + membership: OrgMember | 'super_admin', + permission: Permission, +): boolean { if (membership === 'super_admin') return true; // Check custom permission overrides first diff --git a/apps/api/src/lib/schedule-utils.ts b/apps/api/src/lib/schedule-utils.ts index 7b5f536..2593b30 100644 --- a/apps/api/src/lib/schedule-utils.ts +++ b/apps/api/src/lib/schedule-utils.ts @@ -1,10 +1,7 @@ /** * Compute the next run time for a scheduled task. */ -export function computeNextRun( - scheduleType: string, - scheduleData: Record, -): Date { +export function computeNextRun(scheduleType: string, scheduleData: Record): Date { const now = new Date(); switch (scheduleType) { diff --git a/apps/api/src/lib/server-automation.ts b/apps/api/src/lib/server-automation.ts index 2851aa7..21b4021 100644 --- a/apps/api/src/lib/server-automation.ts +++ b/apps/api/src/lib/server-automation.ts @@ -171,10 +171,7 @@ function readWorkflowId(value: unknown): string | null { return id; } -function normalizeWorkflow( - gameSlug: string, - workflow: GameAutomationRule, -): GameAutomationRule { +function normalizeWorkflow(gameSlug: string, workflow: GameAutomationRule): GameAutomationRule { if (gameSlug.toLowerCase() !== 'cs2') return workflow; if (workflow.id === 'cs2-write-default-server-config') { @@ -237,9 +234,7 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] } const existingIds = new Set( - raw - .map(readWorkflowId) - .filter((workflowId): workflowId is string => workflowId !== null), + raw.map(readWorkflowId).filter((workflowId): workflowId is string => workflowId !== null), ); const missingDefaults = defaults.filter((workflow) => !existingIds.has(workflow.id)); @@ -247,7 +242,9 @@ function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] return configured.map((workflow) => normalizeWorkflow(gameSlug, workflow)); } - return [...configured, ...missingDefaults].map((workflow) => normalizeWorkflow(gameSlug, workflow)); + return [...configured, ...missingDefaults].map((workflow) => + normalizeWorkflow(gameSlug, workflow), + ); } function markerPath(event: ServerAutomationEvent, workflowId: string): string { @@ -386,9 +383,7 @@ interface DirectoryAssetCandidate { function extractNumberParts(value: string): number[] { const matches = value.match(/\d+/g); if (!matches) return []; - return matches - .map((part) => Number.parseInt(part, 10)) - .filter((num) => Number.isFinite(num)); + return matches.map((part) => Number.parseInt(part, 10)).filter((num) => Number.isFinite(num)); } function compareNumberPartsDesc(a: number[], b: number[]): number { @@ -431,7 +426,9 @@ function extractDirectoryCandidates( try { const resolvedUrl = new URL(href, indexUrl); - const filename = decodeURIComponent(resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? ''); + const filename = decodeURIComponent( + resolvedUrl.pathname.split('/').filter(Boolean).pop() ?? '', + ); if (!filename || !assetPattern.test(filename)) continue; candidates.push({ @@ -611,7 +608,8 @@ async function executeGitHubReleaseExtract( ); } - const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES; + const maxBytes = + Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES; const artifact = await downloadBinary(asset.browser_download_url, maxBytes); const files = await extractArtifactFiles( artifact, @@ -651,7 +649,8 @@ async function executeHttpDirectoryExtract( action: ServerAutomationHttpDirectoryExtractAction, ): Promise { const selectedAsset = await resolveLatestDirectoryAsset(action); - const maxBytes = Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES; + const maxBytes = + Number(action.maxBytes) > 0 ? Number(action.maxBytes) : DEFAULT_RELEASE_MAX_BYTES; const artifact = await downloadBinary(selectedAsset.downloadUrl, maxBytes); const files = await extractArtifactFiles( artifact, @@ -701,9 +700,7 @@ async function executeInsertBeforeLine( const skipIfExists = action.skipIfExists !== false; if (skipIfExists) { - const existsRegex = action.existsPattern - ? new RegExp(action.existsPattern, 'i') - : null; + const existsRegex = action.existsPattern ? new RegExp(action.existsPattern, 'i') : null; const alreadyExists = lines.some((line) => existsRegex ? existsRegex.test(line) : line === action.line, @@ -777,9 +774,7 @@ async function executeAction( case 'write_file': { const payload = - action.encoding === 'base64' - ? Buffer.from(action.data, 'base64') - : action.data; + action.encoding === 'base64' ? Buffer.from(action.data, 'base64') : action.data; await daemonWriteFile(context.node, context.serverUuid, action.path, payload); app.log.info( @@ -847,7 +842,7 @@ export async function runServerAutomationEvent( if ( runOnce && !context.force && - await hasMarker(context.node, context.serverUuid, context.event, workflow.id) + (await hasMarker(context.node, context.serverUuid, context.event, workflow.id)) ) { result.workflowsSkipped += 1; app.log.info( diff --git a/apps/api/src/plugins/db.ts b/apps/api/src/plugins/db.ts index c5c4ec8..5c5df1f 100644 --- a/apps/api/src/plugins/db.ts +++ b/apps/api/src/plugins/db.ts @@ -18,7 +18,8 @@ export default fp(async (app: FastifyInstance) => { const db = createDb(databaseUrl); app.decorate('db', db); - await db.execute(sql.raw(` + await db.execute( + sql.raw(` CREATE TABLE IF NOT EXISTS server_databases ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), server_id uuid NOT NULL REFERENCES servers(id) ON DELETE CASCADE, @@ -32,7 +33,8 @@ export default fp(async (app: FastifyInstance) => { created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ) - `)); + `), + ); await db.execute( sql.raw( 'CREATE INDEX IF NOT EXISTS server_databases_server_id_idx ON server_databases(server_id)', diff --git a/apps/api/src/plugins/socket.ts b/apps/api/src/plugins/socket.ts index ddc8e01..e4fbd03 100644 --- a/apps/api/src/plugins/socket.ts +++ b/apps/api/src/plugins/socket.ts @@ -59,9 +59,8 @@ export default fp(async (app: FastifyInstance) => { }; io.use((socket, next) => { - const token = typeof socket.handshake.auth?.token === 'string' - ? socket.handshake.auth.token - : null; + const token = + typeof socket.handshake.auth?.token === 'string' ? socket.handshake.auth.token : null; if (!token) { next(new Error('Unauthorized')); @@ -102,9 +101,10 @@ export default fp(async (app: FastifyInstance) => { }; socket.on('server:console:join', async (payload: unknown) => { - const serverId = typeof (payload as { serverId?: unknown })?.serverId === 'string' - ? ((payload as { serverId: string }).serverId) - : ''; + const serverId = + typeof (payload as { serverId?: unknown })?.serverId === 'string' + ? (payload as { serverId: string }).serverId + : ''; if (!serverId) { socket.emit('server:console:output', { line: '[error] Invalid server id' }); return; @@ -202,9 +202,8 @@ export default fp(async (app: FastifyInstance) => { const serverId = typeof body.serverId === 'string' ? body.serverId : ''; const orgId = typeof body.orgId === 'string' ? body.orgId : ''; const command = typeof body.command === 'string' ? body.command.trim() : ''; - const requestId = typeof body.requestId === 'string' && body.requestId.trim() - ? body.requestId.trim() - : null; + const requestId = + typeof body.requestId === 'string' && body.requestId.trim() ? body.requestId.trim() : null; if (!serverId || !orgId || !command) { socket.emit('server:console:output', { line: '[error] Invalid command payload' }); diff --git a/apps/api/src/routes/admin/index.ts b/apps/api/src/routes/admin/index.ts index 634533f..364cfc0 100644 --- a/apps/api/src/routes/admin/index.ts +++ b/apps/api/src/routes/admin/index.ts @@ -86,10 +86,7 @@ function parseJsonArrayField(rawValue: unknown, fieldName: string): unknown[] { return parsed; } -function parseJsonArrayUploadFile( - file: UploadJsonFile | null, - fieldName: string, -): unknown[] { +function parseJsonArrayUploadFile(file: UploadJsonFile | null, fieldName: string): unknown[] { if (!file) return []; let rawValue = file.data.toString('utf8'); @@ -115,8 +112,10 @@ function parseOptionalBoolean(rawValue: unknown): boolean | undefined { if (typeof rawValue !== 'string') return undefined; const normalized = rawValue.trim().toLowerCase(); - if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true; - if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false; + if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') + return true; + if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') + return false; return undefined; } @@ -124,7 +123,8 @@ function parseReleaseChannel(rawValue: unknown): ReleaseChannel { if (rawValue === 'alpha' || rawValue === 'beta' || rawValue === 'stable') return rawValue; if (typeof rawValue === 'string') { const normalized = rawValue.trim().toLowerCase(); - if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') return normalized; + if (normalized === 'alpha' || normalized === 'beta' || normalized === 'stable') + return normalized; } return 'stable'; } @@ -228,10 +228,7 @@ export default async function adminRoutes(app: FastifyInstance) { // GET /api/admin/games app.get('/games', async () => { - const gameList = await app.db - .select() - .from(games) - .orderBy(games.name); + const gameList = await app.db.select().from(games).orderBy(games.name); return { data: gameList }; }); @@ -271,20 +268,24 @@ export default async function adminRoutes(app: FastifyInstance) { }); // PATCH /api/admin/games/:gameId - app.patch('/games/:gameId', { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, async (request) => { - const { gameId } = request.params as { gameId: string }; - const body = request.body as Record; + app.patch( + '/games/:gameId', + { schema: { ...GameIdParamSchema, ...UpdateGameSchema } }, + async (request) => { + const { gameId } = request.params as { gameId: string }; + const body = request.body as Record; - const [updated] = await app.db - .update(games) - .set({ ...body, updatedAt: new Date() }) - .where(eq(games.id, gameId)) - .returning(); + const [updated] = await app.db + .update(games) + .set({ ...body, updatedAt: new Date() }) + .where(eq(games.id, gameId)) + .returning(); - if (!updated) throw AppError.notFound('Game not found'); + if (!updated) throw AppError.notFound('Game not found'); - return updated; - }); + return updated; + }, + ); // === Nodes (global view) === @@ -581,49 +582,56 @@ export default async function adminRoutes(app: FastifyInstance) { }; }); - app.patch('/plugins/:pluginId', { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, async (request) => { - const { pluginId } = request.params as { pluginId: string }; - const body = request.body as { - name?: string; - slug?: string; - description?: string; - source?: 'manual' | 'spiget'; - isGlobal?: boolean; - }; + app.patch( + '/plugins/:pluginId', + { schema: { ...PluginIdParamSchema, ...UpdateGlobalPluginSchema } }, + async (request) => { + const { pluginId } = request.params as { pluginId: string }; + const body = request.body as { + name?: string; + slug?: string; + description?: string; + source?: 'manual' | 'spiget'; + isGlobal?: boolean; + }; - const existing = await app.db.query.plugins.findFirst({ - where: eq(plugins.id, pluginId), - }); - if (!existing) throw AppError.notFound('Plugin not found'); + const existing = await app.db.query.plugins.findFirst({ + where: eq(plugins.id, pluginId), + }); + if (!existing) throw AppError.notFound('Plugin not found'); - const nextSlug = body.slug !== undefined - ? toSlug(body.slug) - : (body.name !== undefined ? toSlug(body.name) : existing.slug); - if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid'); + const nextSlug = + body.slug !== undefined + ? toSlug(body.slug) + : body.name !== undefined + ? toSlug(body.name) + : existing.slug; + if (!nextSlug) throw AppError.badRequest('Plugin slug is invalid'); - const duplicate = await app.db.query.plugins.findFirst({ - where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)), - }); - if (duplicate && duplicate.id !== existing.id) { - throw AppError.conflict('Plugin slug already exists for this game'); - } + const duplicate = await app.db.query.plugins.findFirst({ + where: and(eq(plugins.gameId, existing.gameId), eq(plugins.slug, nextSlug)), + }); + if (duplicate && duplicate.id !== existing.id) { + throw AppError.conflict('Plugin slug already exists for this game'); + } - const [updated] = await app.db - .update(plugins) - .set({ - name: body.name ?? existing.name, - slug: nextSlug, - description: body.description ?? existing.description, - source: body.source ?? existing.source, - isGlobal: body.isGlobal ?? existing.isGlobal, - updatedAt: new Date(), - }) - .where(eq(plugins.id, existing.id)) - .returning(); + const [updated] = await app.db + .update(plugins) + .set({ + name: body.name ?? existing.name, + slug: nextSlug, + description: body.description ?? existing.description, + source: body.source ?? existing.source, + isGlobal: body.isGlobal ?? existing.isGlobal, + updatedAt: new Date(), + }) + .where(eq(plugins.id, existing.id)) + .returning(); - if (!updated) throw AppError.notFound('Plugin not found'); - return updated; - }); + if (!updated) throw AppError.notFound('Plugin not found'); + return updated; + }, + ); app.get('/plugins/:pluginId/releases', { schema: PluginIdParamSchema }, async (request) => { const { pluginId } = request.params as { pluginId: string }; @@ -642,216 +650,231 @@ export default async function adminRoutes(app: FastifyInstance) { return { plugin, releases }; }); - app.post('/plugins/:pluginId/releases/upload', { schema: PluginIdParamSchema }, async (request, reply) => { - const { pluginId } = request.params as { pluginId: string }; + app.post( + '/plugins/:pluginId/releases/upload', + { schema: PluginIdParamSchema }, + async (request, reply) => { + const { pluginId } = request.params as { pluginId: string }; - const plugin = await app.db.query.plugins.findFirst({ - where: eq(plugins.id, pluginId), - }); - if (!plugin) throw AppError.notFound('Plugin not found'); + const plugin = await app.db.query.plugins.findFirst({ + where: eq(plugins.id, pluginId), + }); + if (!plugin) throw AppError.notFound('Plugin not found'); - if (!request.isMultipart()) { - throw AppError.badRequest('Content-Type must be multipart/form-data'); - } + if (!request.isMultipart()) { + throw AppError.badRequest('Content-Type must be multipart/form-data'); + } - const fields: Record = {}; - const files: UploadArtifactFile[] = []; - let installSchemaFile: UploadJsonFile | null = null; - let configTemplatesFile: UploadJsonFile | null = null; - const relativePathQueue: string[] = []; + const fields: Record = {}; + const files: UploadArtifactFile[] = []; + let installSchemaFile: UploadJsonFile | null = null; + let configTemplatesFile: UploadJsonFile | null = null; + const relativePathQueue: string[] = []; - for await (const part of request.parts()) { - if (part.type === 'file') { - if (part.fieldname === 'installSchemaFile') { - const data = await part.toBuffer(); - if (data.length > 0) { - installSchemaFile = { - filename: part.filename || 'install-schema.json', - data, - }; + for await (const part of request.parts()) { + if (part.type === 'file') { + if (part.fieldname === 'installSchemaFile') { + const data = await part.toBuffer(); + if (data.length > 0) { + installSchemaFile = { + filename: part.filename || 'install-schema.json', + data, + }; + } + continue; } - continue; - } - if (part.fieldname === 'configTemplatesFile') { - const data = await part.toBuffer(); - if (data.length > 0) { - configTemplatesFile = { - filename: part.filename || 'config-templates.json', - data, - }; + if (part.fieldname === 'configTemplatesFile') { + const data = await part.toBuffer(); + if (data.length > 0) { + configTemplatesFile = { + filename: part.filename || 'config-templates.json', + data, + }; + } + continue; } - continue; - } - const fallbackName = `artifact-${files.length + 1}.bin`; - const queuedPath = relativePathQueue.shift(); - const relativePath = normalizeRelativePath( - queuedPath ?? part.filename ?? '', - fallbackName, - ); - const data = await part.toBuffer(); - if (data.length === 0) continue; - files.push({ relativePath, data }); + const fallbackName = `artifact-${files.length + 1}.bin`; + const queuedPath = relativePathQueue.shift(); + const relativePath = normalizeRelativePath( + queuedPath ?? part.filename ?? '', + fallbackName, + ); + const data = await part.toBuffer(); + if (data.length === 0) continue; + files.push({ relativePath, data }); + } else { + if (part.fieldname === 'relativePath') { + const raw = typeof part.value === 'string' ? part.value : ''; + relativePathQueue.push(raw); + continue; + } + + fields[part.fieldname] = part.value; + } + } + + if (files.length === 0) { + throw AppError.badRequest('At least one file is required'); + } + + const version = typeof fields.version === 'string' ? fields.version.trim() : ''; + if (!version) { + throw AppError.badRequest('version is required'); + } + + const channel = parseReleaseChannel(fields.channel); + const destination = + typeof fields.destination === 'string' && fields.destination.trim().length > 0 + ? fields.destination.trim() + : null; + const changelog = + typeof fields.changelog === 'string' && fields.changelog.trim().length > 0 + ? fields.changelog + : null; + const isPublished = parseOptionalBoolean(fields.isPublished) ?? true; + const installSchema = parseJsonArrayInput( + fields.installSchema, + installSchemaFile, + 'installSchema', + ); + const configTemplates = parseJsonArrayInput( + fields.configTemplates, + configTemplatesFile, + 'configTemplates', + ); + + const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : ''; + const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/')); + const shouldZip = files.length > 1 || hasNestedPaths; + + let artifactType: 'file' | 'zip'; + let artifactContent: Buffer; + let uploadFileName: string; + let releaseFileName: string | null; + + if (shouldZip) { + artifactType = 'zip'; + artifactContent = await zipArtifacts(files); + + const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`; + uploadFileName = suggestedName.toLowerCase().endsWith('.zip') + ? suggestedName + : `${suggestedName}.zip`; + releaseFileName = null; } else { - if (part.fieldname === 'relativePath') { - const raw = typeof part.value === 'string' ? part.value : ''; - relativePathQueue.push(raw); - continue; + artifactType = 'file'; + const [singleFile] = files; + if (!singleFile) { + throw AppError.badRequest('No artifact file received'); } - fields[part.fieldname] = part.value; - } - } - - if (files.length === 0) { - throw AppError.badRequest('At least one file is required'); - } - - const version = typeof fields.version === 'string' ? fields.version.trim() : ''; - if (!version) { - throw AppError.badRequest('version is required'); - } - - const channel = parseReleaseChannel(fields.channel); - const destination = typeof fields.destination === 'string' && fields.destination.trim().length > 0 - ? fields.destination.trim() - : null; - const changelog = typeof fields.changelog === 'string' && fields.changelog.trim().length > 0 - ? fields.changelog - : null; - const isPublished = parseOptionalBoolean(fields.isPublished) ?? true; - const installSchema = parseJsonArrayInput(fields.installSchema, installSchemaFile, 'installSchema'); - const configTemplates = parseJsonArrayInput( - fields.configTemplates, - configTemplatesFile, - 'configTemplates', - ); - - const rawFileName = typeof fields.fileName === 'string' ? fields.fileName.trim() : ''; - const hasNestedPaths = files.some((entry) => entry.relativePath.includes('/')); - const shouldZip = files.length > 1 || hasNestedPaths; - - let artifactType: 'file' | 'zip'; - let artifactContent: Buffer; - let uploadFileName: string; - let releaseFileName: string | null; - - if (shouldZip) { - artifactType = 'zip'; - artifactContent = await zipArtifacts(files); - - const suggestedName = rawFileName || `${toSlug(plugin.slug || plugin.name)}-${version}.zip`; - uploadFileName = suggestedName.toLowerCase().endsWith('.zip') - ? suggestedName - : `${suggestedName}.zip`; - releaseFileName = null; - } else { - artifactType = 'file'; - const [singleFile] = files; - if (!singleFile) { - throw AppError.badRequest('No artifact file received'); + artifactContent = singleFile.data; + const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin'; + uploadFileName = rawFileName || originalName; + releaseFileName = uploadFileName; } - artifactContent = singleFile.data; - const originalName = singleFile.relativePath.split('/').pop() ?? 'artifact.bin'; - uploadFileName = rawFileName || originalName; - releaseFileName = uploadFileName; - } - - const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, { - pluginId: plugin.id, - pluginSlug: plugin.slug, - releaseVersion: version, - uploadedBy: request.user.sub, - uploadMode: shouldZip ? 'archive' : 'single', - sourceFileCount: files.length, - }); - - const [created] = await app.db - .insert(pluginReleases) - .values({ + const uploaded = await uploadPluginArtifact(artifactContent, uploadFileName, { pluginId: plugin.id, - version, - channel, - artifactType, - artifactUrl: uploaded.artifactPointer, - destination, - fileName: releaseFileName, - changelog, - installSchema, - configTemplates, - isPublished, - createdByUserId: request.user.sub, - }) - .returning(); + pluginSlug: plugin.slug, + releaseVersion: version, + uploadedBy: request.user.sub, + uploadMode: shouldZip ? 'archive' : 'single', + sourceFileCount: files.length, + }); - return reply.code(201).send({ - release: created, - artifact: { - bucket: uploaded.bucket, - fileId: uploaded.file.id, - storedName: uploaded.file.storedName, - originalName: uploaded.file.originalName, - pointer: uploaded.artifactPointer, - }, - }); - }); + const [created] = await app.db + .insert(pluginReleases) + .values({ + pluginId: plugin.id, + version, + channel, + artifactType, + artifactUrl: uploaded.artifactPointer, + destination, + fileName: releaseFileName, + changelog, + installSchema, + configTemplates, + isPublished, + createdByUserId: request.user.sub, + }) + .returning(); - app.post('/plugins/:pluginId/releases', { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, async (request, reply) => { - const { pluginId } = request.params as { pluginId: string }; - const body = request.body as { - version: string; - channel?: 'stable' | 'beta' | 'alpha'; - artifactType?: 'file' | 'zip'; - artifactUrl: string; - destination?: string; - fileName?: string; - changelog?: string; - installSchema?: unknown[]; - configTemplates?: unknown[]; - isPublished?: boolean; - cloneFromReleaseId?: string; - }; + return reply.code(201).send({ + release: created, + artifact: { + bucket: uploaded.bucket, + fileId: uploaded.file.id, + storedName: uploaded.file.storedName, + originalName: uploaded.file.originalName, + pointer: uploaded.artifactPointer, + }, + }); + }, + ); - const plugin = await app.db.query.plugins.findFirst({ - where: eq(plugins.id, pluginId), - }); - if (!plugin) throw AppError.notFound('Plugin not found'); + app.post( + '/plugins/:pluginId/releases', + { schema: { ...PluginIdParamSchema, ...CreatePluginReleaseSchema } }, + async (request, reply) => { + const { pluginId } = request.params as { pluginId: string }; + const body = request.body as { + version: string; + channel?: 'stable' | 'beta' | 'alpha'; + artifactType?: 'file' | 'zip'; + artifactUrl: string; + destination?: string; + fileName?: string; + changelog?: string; + installSchema?: unknown[]; + configTemplates?: unknown[]; + isPublished?: boolean; + cloneFromReleaseId?: string; + }; - let baseRelease: typeof pluginReleases.$inferSelect | null = null; - if (body.cloneFromReleaseId) { - baseRelease = await app.db.query.pluginReleases.findFirst({ - where: and( - eq(pluginReleases.id, body.cloneFromReleaseId), - eq(pluginReleases.pluginId, pluginId), - ), - }) ?? null; - if (!baseRelease) { - throw AppError.notFound('Clone source release not found'); + const plugin = await app.db.query.plugins.findFirst({ + where: eq(plugins.id, pluginId), + }); + if (!plugin) throw AppError.notFound('Plugin not found'); + + let baseRelease: typeof pluginReleases.$inferSelect | null = null; + if (body.cloneFromReleaseId) { + baseRelease = + (await app.db.query.pluginReleases.findFirst({ + where: and( + eq(pluginReleases.id, body.cloneFromReleaseId), + eq(pluginReleases.pluginId, pluginId), + ), + })) ?? null; + if (!baseRelease) { + throw AppError.notFound('Clone source release not found'); + } } - } - const [created] = await app.db - .insert(pluginReleases) - .values({ - pluginId, - version: body.version, - channel: body.channel ?? baseRelease?.channel ?? 'stable', - artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file', - artifactUrl: body.artifactUrl, - destination: body.destination ?? baseRelease?.destination ?? null, - fileName: body.fileName ?? baseRelease?.fileName ?? null, - changelog: body.changelog ?? baseRelease?.changelog ?? null, - installSchema: body.installSchema ?? baseRelease?.installSchema ?? [], - configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [], - isPublished: body.isPublished ?? baseRelease?.isPublished ?? true, - createdByUserId: request.user.sub, - }) - .returning(); + const [created] = await app.db + .insert(pluginReleases) + .values({ + pluginId, + version: body.version, + channel: body.channel ?? baseRelease?.channel ?? 'stable', + artifactType: body.artifactType ?? baseRelease?.artifactType ?? 'file', + artifactUrl: body.artifactUrl, + destination: body.destination ?? baseRelease?.destination ?? null, + fileName: body.fileName ?? baseRelease?.fileName ?? null, + changelog: body.changelog ?? baseRelease?.changelog ?? null, + installSchema: body.installSchema ?? baseRelease?.installSchema ?? [], + configTemplates: body.configTemplates ?? baseRelease?.configTemplates ?? [], + isPublished: body.isPublished ?? baseRelease?.isPublished ?? true, + createdByUserId: request.user.sub, + }) + .returning(); - return reply.code(201).send(created); - }); + return reply.code(201).send(created); + }, + ); app.patch( '/plugins/:pluginId/releases/:releaseId', @@ -901,10 +924,7 @@ export default async function adminRoutes(app: FastifyInstance) { // GET /api/admin/nodes app.get('/nodes', async () => { - const nodeList = await app.db - .select() - .from(nodes) - .orderBy(nodes.createdAt); + const nodeList = await app.db.select().from(nodes).orderBy(nodes.createdAt); return { data: nodeList }; }); diff --git a/apps/api/src/routes/admin/schemas.ts b/apps/api/src/routes/admin/schemas.ts index 33290d3..03fea55 100644 --- a/apps/api/src/routes/admin/schemas.ts +++ b/apps/api/src/routes/admin/schemas.ts @@ -90,10 +90,14 @@ export const ReleaseInstallFieldSchema = Type.Object({ description: Type.Optional(Type.String({ maxLength: 1000 })), required: Type.Optional(Type.Boolean()), defaultValue: Type.Optional(Type.Any()), - options: Type.Optional(Type.Array(Type.Object({ - label: Type.String({ minLength: 1, maxLength: 255 }), - value: Type.String({ minLength: 1, maxLength: 255 }), - }))), + options: Type.Optional( + Type.Array( + Type.Object({ + label: Type.String({ minLength: 1, maxLength: 255 }), + value: Type.String({ minLength: 1, maxLength: 255 }), + }), + ), + ), min: Type.Optional(Type.Number()), max: Type.Optional(Type.Number()), pattern: Type.Optional(Type.String({ maxLength: 500 })), @@ -107,7 +111,9 @@ export const ReleaseTemplateSchema = Type.Object({ const ImportPluginReleasePayloadSchema = Type.Object({ version: Type.String({ minLength: 1, maxLength: 100 }), - channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])), + channel: Type.Optional( + Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]), + ), artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])), artifactUrl: Type.String({ format: 'uri' }), destination: Type.Optional(Type.String({ minLength: 1 })), @@ -138,7 +144,9 @@ export const ImportPluginsSchema = { export const CreatePluginReleaseSchema = { body: Type.Object({ version: Type.String({ minLength: 1, maxLength: 100 }), - channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])), + channel: Type.Optional( + Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]), + ), artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])), artifactUrl: Type.String({ format: 'uri' }), destination: Type.Optional(Type.String({ minLength: 1 })), @@ -154,7 +162,9 @@ export const CreatePluginReleaseSchema = { export const UpdatePluginReleaseSchema = { body: Type.Object({ version: Type.Optional(Type.String({ minLength: 1, maxLength: 100 })), - channel: Type.Optional(Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')])), + channel: Type.Optional( + Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]), + ), artifactType: Type.Optional(Type.Union([Type.Literal('file'), Type.Literal('zip')])), artifactUrl: Type.Optional(Type.String({ format: 'uri' })), destination: Type.Optional(Type.String({ minLength: 1 })), diff --git a/apps/api/src/routes/games/index.ts b/apps/api/src/routes/games/index.ts index 618b86f..14f7dbd 100644 --- a/apps/api/src/routes/games/index.ts +++ b/apps/api/src/routes/games/index.ts @@ -6,10 +6,7 @@ export default async function gameRoutes(app: FastifyInstance) { // GET /api/games app.get('/', async () => { - const gameList = await app.db - .select() - .from(games) - .orderBy(games.name); + const gameList = await app.db.select().from(games).orderBy(games.name); return { data: gameList }; }); diff --git a/apps/api/src/routes/internal/index.ts b/apps/api/src/routes/internal/index.ts index 32585c4..1aeab2e 100644 --- a/apps/api/src/routes/internal/index.ts +++ b/apps/api/src/routes/internal/index.ts @@ -18,9 +18,8 @@ function extractCdnWebhookSecret(request: FastifyRequest): string | null { return byHeader.trim(); } - const authHeader = typeof request.headers.authorization === 'string' - ? request.headers.authorization - : undefined; + const authHeader = + typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined; return extractBearerToken(authHeader); } @@ -30,9 +29,7 @@ async function requireDaemonToken( request: FastifyRequest, ): Promise<{ id: string }> { const token = extractBearerToken( - typeof request.headers.authorization === 'string' - ? request.headers.authorization - : undefined, + typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined, ); if (!token) { @@ -69,14 +66,14 @@ export default async function internalRoutes(app: FastifyInstance) { } const body = request.body as Record | undefined; - const eventType = typeof body?.eventType === 'string' - ? body.eventType - : (typeof body?.type === 'string' ? body.type : 'unknown'); + const eventType = + typeof body?.eventType === 'string' + ? body.eventType + : typeof body?.type === 'string' + ? body.type + : 'unknown'; - request.log.info( - { eventType, payload: body }, - 'Received CDN plugin webhook event', - ); + request.log.info({ eventType, payload: body }, 'Received CDN plugin webhook event'); return reply.code(202).send({ accepted: true }); }, @@ -98,11 +95,13 @@ export default async function internalRoutes(app: FastifyInstance) { }) .from(scheduledTasks) .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) - .where(and( - eq(servers.nodeId, node.id), - eq(scheduledTasks.isActive, true), - lte(scheduledTasks.nextRunAt, now), - )); + .where( + and( + eq(servers.nodeId, node.id), + eq(scheduledTasks.isActive, true), + lte(scheduledTasks.nextRunAt, now), + ), + ); return { tasks: dueTasks.map((task) => ({ @@ -139,10 +138,7 @@ export default async function internalRoutes(app: FastifyInstance) { }) .from(scheduledTasks) .innerJoin(servers, eq(scheduledTasks.serverId, servers.id)) - .where(and( - eq(scheduledTasks.id, taskId), - eq(servers.nodeId, node.id), - )); + .where(and(eq(scheduledTasks.id, taskId), eq(servers.nodeId, node.id))); if (!task) { throw AppError.notFound('Scheduled task not found'); diff --git a/apps/api/src/routes/nodes/daemon.ts b/apps/api/src/routes/nodes/daemon.ts index cf51fad..517791b 100644 --- a/apps/api/src/routes/nodes/daemon.ts +++ b/apps/api/src/routes/nodes/daemon.ts @@ -23,9 +23,7 @@ export default async function daemonNodeRoutes(app: FastifyInstance) { // POST /api/nodes/heartbeat app.post('/heartbeat', { schema: HeartbeatSchema }, async (request) => { const token = extractBearerToken( - typeof request.headers.authorization === 'string' - ? request.headers.authorization - : undefined, + typeof request.headers.authorization === 'string' ? request.headers.authorization : undefined, ); if (!token) { diff --git a/apps/api/src/routes/nodes/index.ts b/apps/api/src/routes/nodes/index.ts index 201c689..11b3bef 100644 --- a/apps/api/src/routes/nodes/index.ts +++ b/apps/api/src/routes/nodes/index.ts @@ -94,28 +94,32 @@ export default async function nodeRoutes(app: FastifyInstance) { }); // PATCH /api/organizations/:orgId/nodes/:nodeId - app.patch('/:nodeId', { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, async (request) => { - const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; - await requirePermission(request, orgId, 'node.manage'); + app.patch( + '/:nodeId', + { schema: { ...NodeParamSchema, ...UpdateNodeSchema } }, + async (request) => { + const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; + await requirePermission(request, orgId, 'node.manage'); - const body = request.body as Record; + const body = request.body as Record; - const [updated] = await app.db - .update(nodes) - .set({ ...body, updatedAt: new Date() }) - .where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId))) - .returning(); + const [updated] = await app.db + .update(nodes) + .set({ ...body, updatedAt: new Date() }) + .where(and(eq(nodes.id, nodeId), eq(nodes.organizationId, orgId))) + .returning(); - if (!updated) throw AppError.notFound('Node not found'); + if (!updated) throw AppError.notFound('Node not found'); - await createAuditLog(app.db, request, { - organizationId: orgId, - action: 'node.update', - metadata: { nodeId, ...body }, - }); + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'node.update', + metadata: { nodeId, ...body }, + }); - return updated; - }); + return updated; + }, + ); // DELETE /api/organizations/:orgId/nodes/:nodeId app.delete('/:nodeId', { schema: NodeParamSchema }, async (request, reply) => { @@ -244,30 +248,34 @@ export default async function nodeRoutes(app: FastifyInstance) { }); // POST /api/organizations/:orgId/nodes/:nodeId/allocations - app.post('/:nodeId/allocations', { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, async (request, reply) => { - const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; - await requirePermission(request, orgId, 'node.manage'); + app.post( + '/:nodeId/allocations', + { schema: { ...NodeParamSchema, ...CreateAllocationSchema } }, + async (request, reply) => { + const { orgId, nodeId } = request.params as { orgId: string; nodeId: string }; + await requirePermission(request, orgId, 'node.manage'); - const { ip, ports } = request.body as { ip: string; ports: number[] }; + const { ip, ports } = request.body as { ip: string; ports: number[] }; - const values = ports.map((port) => ({ - nodeId, - ip, - port, - })); + const values = ports.map((port) => ({ + nodeId, + ip, + port, + })); - const created = await app.db - .insert(allocations) - .values(values) - .onConflictDoNothing() - .returning(); + const created = await app.db + .insert(allocations) + .values(values) + .onConflictDoNothing() + .returning(); - await createAuditLog(app.db, request, { - organizationId: orgId, - action: 'allocation.create', - metadata: { nodeId, ip, ports }, - }); + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'allocation.create', + metadata: { nodeId, ip, ports }, + }); - return reply.code(201).send({ data: created }); - }); + return reply.code(201).send({ data: created }); + }, + ); } diff --git a/apps/api/src/routes/organizations/index.ts b/apps/api/src/routes/organizations/index.ts index 1a9ccb4..778950f 100644 --- a/apps/api/src/routes/organizations/index.ts +++ b/apps/api/src/routes/organizations/index.ts @@ -174,104 +174,117 @@ export default async function organizationRoutes(app: FastifyInstance) { }); // POST /api/organizations/:orgId/members — invite by email - app.post('/:orgId/members', { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, async (request, reply) => { - const { orgId } = request.params as { orgId: string }; - await requirePermission(request, orgId, 'org.members'); + app.post( + '/:orgId/members', + { schema: { ...OrgIdParamSchema, ...AddMemberSchema } }, + async (request, reply) => { + const { orgId } = request.params as { orgId: string }; + await requirePermission(request, orgId, 'org.members'); - const { email, role } = request.body as { email: string; role: 'admin' | 'user' }; + const { email, role } = request.body as { email: string; role: 'admin' | 'user' }; - const user = await app.db.query.users.findFirst({ - where: eq(users.email, email), - }); - if (!user) throw AppError.notFound('User with this email not found'); + const user = await app.db.query.users.findFirst({ + where: eq(users.email, email), + }); + if (!user) throw AppError.notFound('User with this email not found'); - const existing = await app.db.query.organizationMembers.findFirst({ - where: and( - eq(organizationMembers.organizationId, orgId), - eq(organizationMembers.userId, user.id), - ), - }); - if (existing) throw AppError.conflict('User is already a member'); + const existing = await app.db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.organizationId, orgId), + eq(organizationMembers.userId, user.id), + ), + }); + if (existing) throw AppError.conflict('User is already a member'); - const [member] = await app.db - .insert(organizationMembers) - .values({ + const [member] = await app.db + .insert(organizationMembers) + .values({ + organizationId: orgId, + userId: user.id, + role, + }) + .returning(); + + await createAuditLog(app.db, request, { organizationId: orgId, - userId: user.id, - role, - }) - .returning(); + action: 'member.add', + metadata: { userId: user.id, email, role }, + }); - await createAuditLog(app.db, request, { - organizationId: orgId, - action: 'member.add', - metadata: { userId: user.id, email, role }, - }); - - return reply.code(201).send(member); - }); + return reply.code(201).send(member); + }, + ); // PATCH /api/organizations/:orgId/members/:memberId - app.patch('/:orgId/members/:memberId', { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, async (request) => { - const { orgId, memberId } = request.params as { orgId: string; memberId: string }; - await requirePermission(request, orgId, 'org.members'); + app.patch( + '/:orgId/members/:memberId', + { schema: { ...MemberIdParamSchema, ...UpdateMemberSchema } }, + async (request) => { + const { orgId, memberId } = request.params as { orgId: string; memberId: string }; + await requirePermission(request, orgId, 'org.members'); - const body = request.body as { role?: 'admin' | 'user'; customPermissions?: Record }; + const body = request.body as { + role?: 'admin' | 'user'; + customPermissions?: Record; + }; - const [updated] = await app.db - .update(organizationMembers) - .set(body) - .where(and( - eq(organizationMembers.id, memberId), - eq(organizationMembers.organizationId, orgId), - )) - .returning(); + const [updated] = await app.db + .update(organizationMembers) + .set(body) + .where( + and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)), + ) + .returning(); - if (!updated) throw AppError.notFound('Member not found'); + if (!updated) throw AppError.notFound('Member not found'); - await createAuditLog(app.db, request, { - organizationId: orgId, - action: 'member.update', - metadata: { memberId, ...body }, - }); + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'member.update', + metadata: { memberId, ...body }, + }); - return updated; - }); + return updated; + }, + ); // DELETE /api/organizations/:orgId/members/:memberId - app.delete('/:orgId/members/:memberId', { schema: MemberIdParamSchema }, async (request, reply) => { - const { orgId, memberId } = request.params as { orgId: string; memberId: string }; - await requirePermission(request, orgId, 'org.members'); + app.delete( + '/:orgId/members/:memberId', + { schema: MemberIdParamSchema }, + async (request, reply) => { + const { orgId, memberId } = request.params as { orgId: string; memberId: string }; + await requirePermission(request, orgId, 'org.members'); - const member = await app.db.query.organizationMembers.findFirst({ - where: and( - eq(organizationMembers.id, memberId), - eq(organizationMembers.organizationId, orgId), - ), - }); - if (!member) throw AppError.notFound('Member not found'); + const member = await app.db.query.organizationMembers.findFirst({ + where: and( + eq(organizationMembers.id, memberId), + eq(organizationMembers.organizationId, orgId), + ), + }); + if (!member) throw AppError.notFound('Member not found'); - // Cannot remove org owner - const org = await app.db.query.organizations.findFirst({ - where: eq(organizations.id, orgId), - }); - if (org && member.userId === org.ownerId) { - throw AppError.badRequest('Cannot remove the organization owner'); - } + // Cannot remove org owner + const org = await app.db.query.organizations.findFirst({ + where: eq(organizations.id, orgId), + }); + if (org && member.userId === org.ownerId) { + throw AppError.badRequest('Cannot remove the organization owner'); + } - await app.db - .delete(organizationMembers) - .where(and( - eq(organizationMembers.id, memberId), - eq(organizationMembers.organizationId, orgId), - )); + await app.db + .delete(organizationMembers) + .where( + and(eq(organizationMembers.id, memberId), eq(organizationMembers.organizationId, orgId)), + ); - await createAuditLog(app.db, request, { - organizationId: orgId, - action: 'member.remove', - metadata: { memberId, userId: member.userId }, - }); + await createAuditLog(app.db, request, { + organizationId: orgId, + action: 'member.remove', + metadata: { memberId, userId: member.userId }, + }); - return reply.code(204).send(); - }); + return reply.code(204).send(); + }, + ); } diff --git a/apps/api/src/routes/servers/backups.ts b/apps/api/src/routes/servers/backups.ts index 65f91a7..821fbd0 100644 --- a/apps/api/src/routes/servers/backups.ts +++ b/apps/api/src/routes/servers/backups.ts @@ -100,7 +100,10 @@ export default async function backupRoutes(app: FastifyInstance) { completedBackup = updated ?? completedBackup; } catch (error) { - request.log.error({ error, serverId, backupId: backup.id }, 'Failed to create backup on daemon'); + request.log.error( + { error, serverId, backupId: backup.id }, + 'Failed to create backup on daemon', + ); await app.db.delete(backups).where(eq(backups.id, backup.id)); throw new AppError(502, 'Failed to create backup on daemon', 'DAEMON_BACKUP_CREATE_FAILED'); } @@ -140,10 +143,7 @@ export default async function backupRoutes(app: FastifyInstance) { backup.cdnPath, ); } catch (error) { - request.log.error( - { error, serverId, backupId }, - 'Failed to restore backup on daemon', - ); + request.log.error({ error, serverId, backupId }, 'Failed to restore backup on daemon'); throw new AppError(502, 'Failed to restore backup on daemon', 'DAEMON_BACKUP_RESTORE_FAILED'); } @@ -200,10 +200,7 @@ export default async function backupRoutes(app: FastifyInstance) { try { await daemonDeleteBackup(serverContext.node, serverContext.serverUuid, backup.id); } catch (error) { - request.log.error( - { error, serverId, backupId }, - 'Failed to delete backup on daemon', - ); + request.log.error({ error, serverId, backupId }, 'Failed to delete backup on daemon'); throw new AppError(502, 'Failed to delete backup on daemon', 'DAEMON_BACKUP_DELETE_FAILED'); } diff --git a/apps/api/src/routes/servers/config.ts b/apps/api/src/routes/servers/config.ts index d837270..28a77a5 100644 --- a/apps/api/src/routes/servers/config.ts +++ b/apps/api/src/routes/servers/config.ts @@ -66,7 +66,12 @@ export default async function configRoutes(app: FastifyInstance) { }; await requirePermission(request, orgId, 'config.read'); - const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); + const { game, server, node, configFile } = await getServerConfig( + app, + orgId, + serverId, + configIndex, + ); let raw = ''; try { @@ -79,8 +84,15 @@ export default async function configRoutes(app: FastifyInstance) { } } catch (error) { if (!isMissingConfigFileError(error)) { - app.log.error({ error, serverId, path: configFile.path }, 'Failed to read config file from daemon'); - throw new AppError(502, 'Failed to read config file from daemon', 'DAEMON_CONFIG_READ_FAILED'); + app.log.error( + { error, serverId, path: configFile.path }, + 'Failed to read config file from daemon', + ); + throw new AppError( + 502, + 'Failed to read config file from daemon', + 'DAEMON_CONFIG_READ_FAILED', + ); } } @@ -119,7 +131,12 @@ export default async function configRoutes(app: FastifyInstance) { const { entries } = request.body as { entries: { key: string; value: string }[] }; await requirePermission(request, orgId, 'config.write'); - const { game, server, node, configFile } = await getServerConfig(app, orgId, serverId, configIndex); + const { game, server, node, configFile } = await getServerConfig( + app, + orgId, + serverId, + configIndex, + ); const managedFile = managedConfigFileFor(game.slug, configFile.path); @@ -135,8 +152,15 @@ export default async function configRoutes(app: FastifyInstance) { originalEntries = parseConfig(originalContent, configFile.parser as ConfigParser); } catch (error) { if (!isMissingConfigFileError(error)) { - app.log.error({ error, serverId, path: configFile.path }, 'Failed to read existing config before write'); - throw new AppError(502, 'Failed to read existing config file', 'DAEMON_CONFIG_READ_FAILED'); + app.log.error( + { error, serverId, path: configFile.path }, + 'Failed to read existing config before write', + ); + throw new AppError( + 502, + 'Failed to read existing config file', + 'DAEMON_CONFIG_READ_FAILED', + ); } } @@ -156,11 +180,7 @@ export default async function configRoutes(app: FastifyInstance) { } } - const content = serializeConfig( - entries, - configFile.parser as ConfigParser, - originalContent, - ); + const content = serializeConfig(entries, configFile.parser as ConfigParser, originalContent); if (managedFile) { await writeManagedConfig(node, server.uuid, managedFile, content); diff --git a/apps/api/src/routes/servers/databases.ts b/apps/api/src/routes/servers/databases.ts index b2aa052..fddbc8c 100644 --- a/apps/api/src/routes/servers/databases.ts +++ b/apps/api/src/routes/servers/databases.ts @@ -99,180 +99,188 @@ export default async function databaseRoutes(app: FastifyInstance) { return { data: databases }; }); - app.post('/', { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, async (request, reply) => { - const { orgId, serverId } = request.params as { orgId: string; serverId: string }; - await requirePermission(request, orgId, 'server.update'); + app.post( + '/', + { schema: { ...ServerScopeSchema, ...CreateServerDatabaseSchema } }, + async (request, reply) => { + const { orgId, serverId } = request.params as { orgId: string; serverId: string }; + await requirePermission(request, orgId, 'server.update'); - const body = request.body as { name: string; password?: string }; - const name = body.name.trim(); - if (!name) { - throw AppError.badRequest('Database name is required'); - } + const body = request.body as { name: string; password?: string }; + const name = body.name.trim(); + if (!name) { + throw AppError.badRequest('Database name is required'); + } - const server = await getServerContext(app, orgId, serverId); + const server = await getServerContext(app, orgId, serverId); - let managedDatabase; - try { - managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), { - name, - password: body.password, - serverUuid: server.uuid, - }); - } catch (error) { - request.log.error( - { error, orgId, serverId, serverUuid: server.uuid }, - 'Failed to provision node-local MySQL database', - ); - throw new AppError( - 502, - daemonErrorMessage(error, 'Failed to provision node-local MySQL database'), - 'MANAGED_MYSQL_CREATE_FAILED', - ); - } - - try { - const [created] = await app.db - .insert(serverDatabases) - .values({ - serverId, + let managedDatabase; + try { + managedDatabase = await daemonCreateDatabase(buildNodeConnection(server), { name, - databaseName: managedDatabase.databaseName, - username: managedDatabase.username, - password: managedDatabase.password, - host: managedDatabase.host, - port: managedDatabase.port, - phpMyAdminUrl: managedDatabase.phpMyAdminUrl, + password: body.password, + serverUuid: server.uuid, + }); + } catch (error) { + request.log.error( + { error, orgId, serverId, serverUuid: server.uuid }, + 'Failed to provision node-local MySQL database', + ); + throw new AppError( + 502, + daemonErrorMessage(error, 'Failed to provision node-local MySQL database'), + 'MANAGED_MYSQL_CREATE_FAILED', + ); + } + + try { + const [created] = await app.db + .insert(serverDatabases) + .values({ + serverId, + name, + databaseName: managedDatabase.databaseName, + username: managedDatabase.username, + password: managedDatabase.password, + host: managedDatabase.host, + port: managedDatabase.port, + phpMyAdminUrl: managedDatabase.phpMyAdminUrl, + }) + .returning(); + + await createAuditLog(app.db, request, { + organizationId: orgId, + serverId, + action: 'server.database.create', + metadata: { + name: created!.name, + databaseName: created!.databaseName, + username: created!.username, + }, + }); + + return reply.code(201).send(created); + } catch (error) { + try { + await daemonDeleteDatabase(buildNodeConnection(server), { + databaseName: managedDatabase.databaseName, + username: managedDatabase.username, + }); + } catch (cleanupError) { + request.log.error( + { cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName }, + 'Failed to roll back node-local MySQL database after panel insert failure', + ); + } + + request.log.error( + { error, orgId, serverId, databaseName: managedDatabase.databaseName }, + 'Failed to persist managed MySQL database metadata', + ); + throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED'); + } + }, + ); + + app.patch( + '/:databaseId', + { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, + async (request) => { + const { orgId, serverId, databaseId } = request.params as { + databaseId: string; + orgId: string; + serverId: string; + }; + await requirePermission(request, orgId, 'server.update'); + + const body = request.body as { name?: string; password?: string }; + + const [current] = await app.db + .select({ + id: serverDatabases.id, + name: serverDatabases.name, + databaseName: serverDatabases.databaseName, + username: serverDatabases.username, + password: serverDatabases.password, + host: serverDatabases.host, + port: serverDatabases.port, + phpMyAdminUrl: serverDatabases.phpMyAdminUrl, + createdAt: serverDatabases.createdAt, + updatedAt: serverDatabases.updatedAt, + nodeFqdn: nodes.fqdn, + nodeGrpcPort: nodes.grpcPort, + nodeDaemonToken: nodes.daemonToken, }) + .from(serverDatabases) + .innerJoin(servers, eq(serverDatabases.serverId, servers.id)) + .innerJoin(nodes, eq(servers.nodeId, nodes.id)) + .where( + and( + eq(serverDatabases.id, databaseId), + eq(serverDatabases.serverId, serverId), + eq(servers.organizationId, orgId), + ), + ); + + if (!current) { + throw AppError.notFound('Database not found'); + } + + const nextName = body.name === undefined ? undefined : body.name.trim(); + if (body.name !== undefined && !nextName) { + throw AppError.badRequest('Database name is required'); + } + + const nextPassword = body.password?.trim(); + if (!nextName && !nextPassword) { + return current; + } + + if (nextPassword) { + try { + await daemonUpdateDatabasePassword(buildNodeConnection(current), { + password: nextPassword, + username: current.username, + }); + } catch (error) { + request.log.error( + { error, orgId, serverId, databaseId, username: current.username }, + 'Failed to rotate node-local MySQL password', + ); + throw new AppError( + 502, + daemonErrorMessage(error, 'Failed to rotate database password'), + 'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED', + ); + } + } + + const patch: Record = { + updatedAt: new Date(), + }; + if (nextName) patch.name = nextName; + if (nextPassword) patch.password = nextPassword; + + const [updated] = await app.db + .update(serverDatabases) + .set(patch) + .where(eq(serverDatabases.id, databaseId)) .returning(); await createAuditLog(app.db, request, { organizationId: orgId, serverId, - action: 'server.database.create', + action: 'server.database.update', metadata: { - name: created!.name, - databaseName: created!.databaseName, - username: created!.username, + databaseId, + updatedName: nextName ?? undefined, + passwordRotated: Boolean(nextPassword), }, }); - return reply.code(201).send(created); - } catch (error) { - try { - await daemonDeleteDatabase(buildNodeConnection(server), { - databaseName: managedDatabase.databaseName, - username: managedDatabase.username, - }); - } catch (cleanupError) { - request.log.error( - { cleanupError, orgId, serverId, databaseName: managedDatabase.databaseName }, - 'Failed to roll back node-local MySQL database after panel insert failure', - ); - } - - request.log.error( - { error, orgId, serverId, databaseName: managedDatabase.databaseName }, - 'Failed to persist managed MySQL database metadata', - ); - throw new AppError(500, 'Failed to save database metadata', 'SERVER_DATABASE_SAVE_FAILED'); - } - }); - - app.patch('/:databaseId', { schema: { ...ServerDatabaseParamSchema, ...UpdateServerDatabaseSchema } }, async (request) => { - const { orgId, serverId, databaseId } = request.params as { - databaseId: string; - orgId: string; - serverId: string; - }; - await requirePermission(request, orgId, 'server.update'); - - const body = request.body as { name?: string; password?: string }; - - const [current] = await app.db - .select({ - id: serverDatabases.id, - name: serverDatabases.name, - databaseName: serverDatabases.databaseName, - username: serverDatabases.username, - password: serverDatabases.password, - host: serverDatabases.host, - port: serverDatabases.port, - phpMyAdminUrl: serverDatabases.phpMyAdminUrl, - createdAt: serverDatabases.createdAt, - updatedAt: serverDatabases.updatedAt, - nodeFqdn: nodes.fqdn, - nodeGrpcPort: nodes.grpcPort, - nodeDaemonToken: nodes.daemonToken, - }) - .from(serverDatabases) - .innerJoin(servers, eq(serverDatabases.serverId, servers.id)) - .innerJoin(nodes, eq(servers.nodeId, nodes.id)) - .where( - and( - eq(serverDatabases.id, databaseId), - eq(serverDatabases.serverId, serverId), - eq(servers.organizationId, orgId), - ), - ); - - if (!current) { - throw AppError.notFound('Database not found'); - } - - const nextName = body.name === undefined ? undefined : body.name.trim(); - if (body.name !== undefined && !nextName) { - throw AppError.badRequest('Database name is required'); - } - - const nextPassword = body.password?.trim(); - if (!nextName && !nextPassword) { - return current; - } - - if (nextPassword) { - try { - await daemonUpdateDatabasePassword(buildNodeConnection(current), { - password: nextPassword, - username: current.username, - }); - } catch (error) { - request.log.error( - { error, orgId, serverId, databaseId, username: current.username }, - 'Failed to rotate node-local MySQL password', - ); - throw new AppError( - 502, - daemonErrorMessage(error, 'Failed to rotate database password'), - 'MANAGED_MYSQL_PASSWORD_UPDATE_FAILED', - ); - } - } - - const patch: Record = { - updatedAt: new Date(), - }; - if (nextName) patch.name = nextName; - if (nextPassword) patch.password = nextPassword; - - const [updated] = await app.db - .update(serverDatabases) - .set(patch) - .where(eq(serverDatabases.id, databaseId)) - .returning(); - - await createAuditLog(app.db, request, { - organizationId: orgId, - serverId, - action: 'server.database.update', - metadata: { - databaseId, - updatedName: nextName ?? undefined, - passwordRotated: Boolean(nextPassword), - }, - }); - - return updated; - }); + return updated; + }, + ); app.delete('/:databaseId', { schema: ServerDatabaseParamSchema }, async (request, reply) => { const { orgId, serverId, databaseId } = request.params as { diff --git a/apps/api/src/routes/servers/files.ts b/apps/api/src/routes/servers/files.ts index c743658..1b9271b 100644 --- a/apps/api/src/routes/servers/files.ts +++ b/apps/api/src/routes/servers/files.ts @@ -121,9 +121,7 @@ export default async function fileRoutes(app: FastifyInstance) { return { data: - requestedEncoding === 'base64' - ? payload.toString('base64') - : payload.toString('utf8'), + requestedEncoding === 'base64' ? payload.toString('base64') : payload.toString('utf8'), encoding: requestedEncoding, mimeType, }; @@ -196,9 +194,7 @@ export default async function fileRoutes(app: FastifyInstance) { return [ path, - path.trim().startsWith('/') - ? `/${managedFile.shadowPath}` - : managedFile.shadowPath, + path.trim().startsWith('/') ? `/${managedFile.shadowPath}` : managedFile.shadowPath, ]; }); @@ -208,7 +204,11 @@ export default async function fileRoutes(app: FastifyInstance) { ); } -async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{ +async function getServerContext( + app: FastifyInstance, + orgId: string, + serverId: string, +): Promise<{ serverUuid: string; gameSlug: string; node: DaemonNodeConnection; diff --git a/apps/api/src/routes/servers/players.ts b/apps/api/src/routes/servers/players.ts index 45770b2..19983de 100644 --- a/apps/api/src/routes/servers/players.ts +++ b/apps/api/src/routes/servers/players.ts @@ -33,7 +33,11 @@ export default async function playerRoutes(app: FastifyInstance) { }); } -async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{ +async function getServerContext( + app: FastifyInstance, + orgId: string, + serverId: string, +): Promise<{ serverUuid: string; node: DaemonNodeConnection; }> { diff --git a/apps/api/src/routes/servers/plugins.ts b/apps/api/src/routes/servers/plugins.ts index 1490422..76e4340 100644 --- a/apps/api/src/routes/servers/plugins.ts +++ b/apps/api/src/routes/servers/plugins.ts @@ -19,11 +19,7 @@ import { daemonWriteFile, type DaemonNodeConnection, } from '../../lib/daemon.js'; -import { - searchSpigetPlugins, - getSpigetResource, - getSpigetDownloadUrl, -} from '../../lib/spiget.js'; +import { searchSpigetPlugins, getSpigetResource, getSpigetDownloadUrl } from '../../lib/spiget.js'; import { resolveArtifactDownloadUrl } from '../../lib/cdn.js'; import * as unzipper from 'unzipper'; @@ -255,10 +251,20 @@ function parseBooleanLike(input: unknown): boolean | null { } if (typeof input === 'string') { const normalized = input.trim().toLowerCase(); - if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') { + if ( + normalized === 'true' || + normalized === '1' || + normalized === 'yes' || + normalized === 'on' + ) { return true; } - if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') { + if ( + normalized === 'false' || + normalized === '0' || + normalized === 'no' || + normalized === 'off' + ) { return false; } } @@ -338,10 +344,12 @@ function validateInstallOptions( return normalized; } -function chooseBestRelease(releases: T[], autoChannel: ReleaseChannel): T | null { +function chooseBestRelease< + T extends { + channel: string; + isPublished: boolean; + }, +>(releases: T[], autoChannel: ReleaseChannel): T | null { for (const release of releases) { if (!release.isPublished) continue; const releaseChannel = resolveChannel(release.channel); @@ -394,11 +402,7 @@ async function getServerPluginContext( }; } -async function getPluginForGame( - app: FastifyInstance, - pluginId: string, - gameId: string, -) { +async function getPluginForGame(app: FastifyInstance, pluginId: string, gameId: string) { const plugin = await app.db.query.plugins.findFirst({ where: and(eq(plugins.id, pluginId), eq(plugins.gameId, gameId)), }); @@ -422,10 +426,7 @@ async function getPluginReleaseForPlugin( return release; } -async function listPublishedPluginReleases( - app: FastifyInstance, - pluginId: string, -) { +async function listPublishedPluginReleases(app: FastifyInstance, pluginId: string) { return app.db .select() .from(pluginReleases) @@ -484,17 +485,16 @@ async function downloadPluginArtifact(downloadUrl: string): Promise { return body; } catch (error) { if (error instanceof AppError) throw error; - throw new AppError( - 502, - 'Unable to download plugin artifact', - 'PLUGIN_DOWNLOAD_FAILED', - ); + throw new AppError(502, 'Unable to download plugin artifact', 'PLUGIN_DOWNLOAD_FAILED'); } finally { clearTimeout(timeout); } } -async function extractZipArtifact(buffer: Buffer, destination: string): Promise> { +async function extractZipArtifact( + buffer: Buffer, + destination: string, +): Promise> { const archive = await unzipper.Open.buffer(buffer); const files: Array<{ path: string; data: Buffer }> = []; @@ -545,15 +545,13 @@ async function insertServerPluginFileRows( ): Promise { if (paths.length === 0) return; - await app.db - .insert(serverPluginFiles) - .values( - uniqPaths(paths).map((path) => ({ - serverPluginId, - path, - kind, - })), - ); + await app.db.insert(serverPluginFiles).values( + uniqPaths(paths).map((path) => ({ + serverPluginId, + path, + kind, + })), + ); } async function installReleaseArtifacts( @@ -643,9 +641,7 @@ async function removeInstalledPluginFiles( .from(serverPluginFiles) .where(eq(serverPluginFiles.serverPluginId, installId)); - const candidates = tracked.length > 0 - ? tracked.map((row) => row.path) - : fallbackPaths; + const candidates = tracked.length > 0 ? tracked.map((row) => row.path) : fallbackPaths; const pathsToDelete = uniqPaths(candidates).filter((path) => !preserveSet.has(path)); @@ -673,17 +669,12 @@ async function syncInstalledPluginConfigFiles( .select({ path: serverPluginFiles.path }) .from(serverPluginFiles) .where( - and( - eq(serverPluginFiles.serverPluginId, installId), - eq(serverPluginFiles.kind, 'config'), - ), + and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')), ); const nextPaths = uniqPaths(configPaths); const nextPathSet = new Set(nextPaths); - const stalePaths = tracked - .map((row) => row.path) - .filter((path) => !nextPathSet.has(path)); + const stalePaths = tracked.map((row) => row.path).filter((path) => !nextPathSet.has(path)); if (stalePaths.length > 0) { try { @@ -704,10 +695,7 @@ async function syncInstalledPluginConfigFiles( await app.db .delete(serverPluginFiles) .where( - and( - eq(serverPluginFiles.serverPluginId, installId), - eq(serverPluginFiles.kind, 'config'), - ), + and(eq(serverPluginFiles.serverPluginId, installId), eq(serverPluginFiles.kind, 'config')), ); await insertServerPluginFileRows(app, installId, nextPaths, 'config'); @@ -830,10 +818,7 @@ async function installPluginForServer( } const existing = await app.db.query.serverPlugins.findFirst({ - where: and( - eq(serverPlugins.serverId, context.serverId), - eq(serverPlugins.pluginId, plugin.id), - ), + where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)), }); if (existing) { throw AppError.conflict('Plugin is already installed'); @@ -902,21 +887,22 @@ export default async function pluginRoutes(app: FastifyInstance) { .where(eq(serverPlugins.serverId, serverId)); const pluginIds = uniqPaths(installed.map((row) => row.pluginId)); - const releases = pluginIds.length > 0 - ? await app.db - .select({ - id: pluginReleases.id, - pluginId: pluginReleases.pluginId, - version: pluginReleases.version, - channel: pluginReleases.channel, - installSchema: pluginReleases.installSchema, - isPublished: pluginReleases.isPublished, - createdAt: pluginReleases.createdAt, - }) - .from(pluginReleases) - .where(inArray(pluginReleases.pluginId, pluginIds)) - .orderBy(desc(pluginReleases.createdAt)) - : []; + const releases = + pluginIds.length > 0 + ? await app.db + .select({ + id: pluginReleases.id, + pluginId: pluginReleases.pluginId, + version: pluginReleases.version, + channel: pluginReleases.channel, + installSchema: pluginReleases.installSchema, + isPublished: pluginReleases.isPublished, + createdAt: pluginReleases.createdAt, + }) + .from(pluginReleases) + .where(inArray(pluginReleases.pluginId, pluginIds)) + .orderBy(desc(pluginReleases.createdAt)) + : []; const releasesByPlugin = new Map(); for (const release of releases) { @@ -929,15 +915,12 @@ export default async function pluginRoutes(app: FastifyInstance) { plugins: installed.map((row) => { const releaseList = releasesByPlugin.get(row.pluginId) ?? []; const currentRelease = row.releaseId - ? releaseList.find((release) => release.id === row.releaseId) ?? null + ? (releaseList.find((release) => release.id === row.releaseId) ?? null) : null; const currentChannel = resolveChannel(row.autoUpdateChannel); const latestAllowed = chooseBestRelease(releaseList, currentChannel); const updateAvailable = Boolean( - !row.isPinned && - latestAllowed && - row.releaseId && - latestAllowed.id !== row.releaseId, + !row.isPinned && latestAllowed && row.releaseId && latestAllowed.id !== row.releaseId, ); return { @@ -1007,25 +990,31 @@ export default async function pluginRoutes(app: FastifyInstance) { .where(eq(serverPlugins.serverId, context.serverId)); const pluginIds = catalog.map((plugin) => plugin.id); - const releaseRows = pluginIds.length > 0 - ? await app.db - .select({ - id: pluginReleases.id, - pluginId: pluginReleases.pluginId, - version: pluginReleases.version, - channel: pluginReleases.channel, - artifactType: pluginReleases.artifactType, - artifactUrl: pluginReleases.artifactUrl, - destination: pluginReleases.destination, - fileName: pluginReleases.fileName, - installSchema: pluginReleases.installSchema, - isPublished: pluginReleases.isPublished, - createdAt: pluginReleases.createdAt, - }) - .from(pluginReleases) - .where(and(inArray(pluginReleases.pluginId, pluginIds), eq(pluginReleases.isPublished, true))) - .orderBy(desc(pluginReleases.createdAt)) - : []; + const releaseRows = + pluginIds.length > 0 + ? await app.db + .select({ + id: pluginReleases.id, + pluginId: pluginReleases.pluginId, + version: pluginReleases.version, + channel: pluginReleases.channel, + artifactType: pluginReleases.artifactType, + artifactUrl: pluginReleases.artifactUrl, + destination: pluginReleases.destination, + fileName: pluginReleases.fileName, + installSchema: pluginReleases.installSchema, + isPublished: pluginReleases.isPublished, + createdAt: pluginReleases.createdAt, + }) + .from(pluginReleases) + .where( + and( + inArray(pluginReleases.pluginId, pluginIds), + eq(pluginReleases.isPublished, true), + ), + ) + .orderBy(desc(pluginReleases.createdAt)) + : []; const releaseByPlugin = new Map(); for (const row of releaseRows) { @@ -1034,9 +1023,7 @@ export default async function pluginRoutes(app: FastifyInstance) { releaseByPlugin.set(row.pluginId, list); } - const installedByPluginId = new Map( - installedRows.map((row) => [row.pluginId, row]), - ); + const installedByPluginId = new Map(installedRows.map((row) => [row.pluginId, row])); const needle = q?.trim().toLowerCase(); const filtered = needle @@ -1133,10 +1120,7 @@ export default async function pluginRoutes(app: FastifyInstance) { } const existing = await app.db.query.plugins.findFirst({ - where: and( - eq(plugins.gameId, context.gameId), - eq(plugins.slug, normalizedSlug), - ), + where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, normalizedSlug)), }); if (existing) { throw AppError.conflict('A plugin with this slug already exists for the game'); @@ -1203,18 +1187,18 @@ export default async function pluginRoutes(app: FastifyInstance) { const context = await getServerPluginContext(app, orgId, serverId); const existing = await getPluginForGame(app, pluginId, context.gameId); - const nextSlug = body.slug !== undefined - ? toSlug(body.slug) - : (body.name !== undefined ? toSlug(body.name) : existing.slug); + const nextSlug = + body.slug !== undefined + ? toSlug(body.slug) + : body.name !== undefined + ? toSlug(body.name) + : existing.slug; if (!nextSlug) { throw AppError.badRequest('Plugin slug is invalid'); } const duplicate = await app.db.query.plugins.findFirst({ - where: and( - eq(plugins.gameId, context.gameId), - eq(plugins.slug, nextSlug), - ), + where: and(eq(plugins.gameId, context.gameId), eq(plugins.slug, nextSlug)), }); if (duplicate && duplicate.id !== existing.id) { throw AppError.conflict('A plugin with this slug already exists for the game'); @@ -1338,16 +1322,16 @@ export default async function pluginRoutes(app: FastifyInstance) { serverId: Type.String({ format: 'uuid' }), pluginId: Type.String({ format: 'uuid' }), }), - body: Type.Optional(Type.Object({ - releaseId: Type.Optional(Type.String({ format: 'uuid' })), - options: Type.Optional(Type.Record(Type.String(), Type.Any())), - pinVersion: Type.Optional(Type.Boolean()), - autoUpdateChannel: Type.Optional(Type.Union([ - Type.Literal('stable'), - Type.Literal('beta'), - Type.Literal('alpha'), - ])), - })), + body: Type.Optional( + Type.Object({ + releaseId: Type.Optional(Type.String({ format: 'uuid' })), + options: Type.Optional(Type.Record(Type.String(), Type.Any())), + pinVersion: Type.Optional(Type.Boolean()), + autoUpdateChannel: Type.Optional( + Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]), + ), + }), + ), }, }, async (request) => { @@ -1368,7 +1352,10 @@ export default async function pluginRoutes(app: FastifyInstance) { const plugin = await getPluginForGame(app, pluginId, context.gameId); const existing = await app.db.query.serverPlugins.findFirst({ - where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)), + where: and( + eq(serverPlugins.serverId, context.serverId), + eq(serverPlugins.pluginId, plugin.id), + ), }); if (existing) { throw AppError.conflict('Plugin is already installed'); @@ -1471,7 +1458,10 @@ export default async function pluginRoutes(app: FastifyInstance) { }, async (request) => { const { orgId, serverId } = request.params as { orgId: string; serverId: string }; - const { resourceId } = request.body as { resourceId: number; options?: Record }; + const { resourceId } = request.body as { + resourceId: number; + options?: Record; + }; await requirePermission(request, orgId, 'plugin.manage'); const context = await getServerPluginContext(app, orgId, serverId); @@ -1508,7 +1498,9 @@ export default async function pluginRoutes(app: FastifyInstance) { plugin = created!; } - const releaseVersion = resource.version ? String(resource.version.id) : `spiget-${Date.now()}`; + const releaseVersion = resource.version + ? String(resource.version.id) + : `spiget-${Date.now()}`; let release = await app.db.query.pluginReleases.findFirst({ where: and( eq(pluginReleases.pluginId, plugin.id), @@ -1533,19 +1525,16 @@ export default async function pluginRoutes(app: FastifyInstance) { } const existing = await app.db.query.serverPlugins.findFirst({ - where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)), + where: and( + eq(serverPlugins.serverId, context.serverId), + eq(serverPlugins.pluginId, plugin.id), + ), }); if (existing) { throw AppError.conflict('Plugin is already installed'); } - const installResult = await installPluginReleaseForServer( - app, - context, - plugin, - release, - {}, - ); + const installResult = await installPluginReleaseForServer(app, context, plugin, release, {}); const [installed] = await app.db .insert(serverPlugins) @@ -1614,9 +1603,7 @@ export default async function pluginRoutes(app: FastifyInstance) { ? normalizeAbsolutePath(filePath) : joinAbsolutePath(pluginInstallDirectory(context.gameSlug), filePath); - let plugin = pluginId - ? await getPluginForGame(app, pluginId, context.gameId) - : null; + let plugin = pluginId ? await getPluginForGame(app, pluginId, context.gameId) : null; if (!plugin) { const slug = toSlug(name); @@ -1642,7 +1629,10 @@ export default async function pluginRoutes(app: FastifyInstance) { } const existingInstall = await app.db.query.serverPlugins.findFirst({ - where: and(eq(serverPlugins.serverId, context.serverId), eq(serverPlugins.pluginId, plugin.id)), + where: and( + eq(serverPlugins.serverId, context.serverId), + eq(serverPlugins.pluginId, plugin.id), + ), }); if (existingInstall) { throw AppError.conflict('Plugin is already installed'); @@ -1669,7 +1659,12 @@ export default async function pluginRoutes(app: FastifyInstance) { organizationId: orgId, serverId, action: 'plugin.install', - metadata: { pluginId: plugin.id, name: plugin.name, source: 'manual', filePath: normalizedPath }, + metadata: { + pluginId: plugin.id, + name: plugin.name, + source: 'manual', + filePath: normalizedPath, + }, }); return installed; @@ -1710,24 +1705,27 @@ export default async function pluginRoutes(app: FastifyInstance) { .from(serverPlugins) .innerJoin(plugins, eq(serverPlugins.pluginId, plugins.id)) .leftJoin(pluginReleases, eq(serverPlugins.releaseId, pluginReleases.id)) - .where(and( - eq(serverPlugins.id, pluginInstallId), - eq(serverPlugins.serverId, context.serverId), - )); + .where( + and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)), + ); if (!installed) { throw AppError.notFound('Plugin installation not found'); } const fallbackPath = installed.releaseArtifactUrl - ? resolveReleaseFilePath(context.gameSlug, { - id: installed.pluginId, - slug: installed.pluginSlug, - }, { - artifactUrl: installed.releaseArtifactUrl, - destination: installed.releaseDestination, - fileName: installed.releaseFileName, - }) + ? resolveReleaseFilePath( + context.gameSlug, + { + id: installed.pluginId, + slug: installed.pluginSlug, + }, + { + artifactUrl: installed.releaseArtifactUrl, + destination: installed.releaseDestination, + fileName: installed.releaseFileName, + }, + ) : pluginFilePath(context.gameSlug, { id: installed.pluginId, slug: installed.pluginSlug, @@ -1809,16 +1807,16 @@ export default async function pluginRoutes(app: FastifyInstance) { serverId: Type.String({ format: 'uuid' }), pluginInstallId: Type.String({ format: 'uuid' }), }), - body: Type.Optional(Type.Object({ - releaseId: Type.Optional(Type.String({ format: 'uuid' })), - options: Type.Optional(Type.Record(Type.String(), Type.Any())), - pinVersion: Type.Optional(Type.Boolean()), - autoUpdateChannel: Type.Optional(Type.Union([ - Type.Literal('stable'), - Type.Literal('beta'), - Type.Literal('alpha'), - ])), - })), + body: Type.Optional( + Type.Object({ + releaseId: Type.Optional(Type.String({ format: 'uuid' })), + options: Type.Optional(Type.Record(Type.String(), Type.Any())), + pinVersion: Type.Optional(Type.Boolean()), + autoUpdateChannel: Type.Optional( + Type.Union([Type.Literal('stable'), Type.Literal('beta'), Type.Literal('alpha')]), + ), + }), + ), }, }, async (request) => { @@ -1847,7 +1845,9 @@ export default async function pluginRoutes(app: FastifyInstance) { autoUpdateChannel: serverPlugins.autoUpdateChannel, }) .from(serverPlugins) - .where(and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId))); + .where( + and(eq(serverPlugins.id, pluginInstallId), eq(serverPlugins.serverId, context.serverId)), + ); if (!installed) { throw AppError.notFound('Plugin installation not found'); @@ -1880,8 +1880,7 @@ export default async function pluginRoutes(app: FastifyInstance) { const nextPinned = body.pinVersion ?? installed.isPinned; const nextAutoUpdateChannel = body.autoUpdateChannel ?? installed.autoUpdateChannel; const hasMetadataChanges = - nextPinned !== installed.isPinned || - nextAutoUpdateChannel !== installed.autoUpdateChannel; + nextPinned !== installed.isPinned || nextAutoUpdateChannel !== installed.autoUpdateChannel; if (!releaseChanged && !hasOptionChanges && !hasMetadataChanges) { throw AppError.conflict('Plugin is already on the selected release'); @@ -1936,11 +1935,24 @@ export default async function pluginRoutes(app: FastifyInstance) { mergedOptions, ); - const newPaths = uniqPaths([...installResult.artifactPaths, ...installResult.configPaths]); + const newPaths = uniqPaths([ + ...installResult.artifactPaths, + ...installResult.configPaths, + ]); await removeInstalledPluginFiles(app, context, installed.installId, [], newPaths); - await insertServerPluginFileRows(app, installed.installId, installResult.artifactPaths, 'artifact'); - await insertServerPluginFileRows(app, installed.installId, installResult.configPaths, 'config'); + await insertServerPluginFileRows( + app, + installed.installId, + installResult.artifactPaths, + 'artifact', + ); + await insertServerPluginFileRows( + app, + installed.installId, + installResult.configPaths, + 'config', + ); nextInstallOptions = installResult.installOptions; } else { const configureResult = await configurePluginReleaseForServer( diff --git a/apps/api/src/routes/servers/schedules.ts b/apps/api/src/routes/servers/schedules.ts index d45f192..c07dcd8 100644 --- a/apps/api/src/routes/servers/schedules.ts +++ b/apps/api/src/routes/servers/schedules.ts @@ -30,11 +30,7 @@ const TaskParamSchema = { const CreateScheduleBody = Type.Object({ name: Type.String({ minLength: 1, maxLength: 255 }), - action: Type.Union([ - Type.Literal('command'), - Type.Literal('power'), - Type.Literal('backup'), - ]), + action: Type.Union([Type.Literal('command'), Type.Literal('power'), Type.Literal('backup')]), payload: Type.String({ minLength: 1 }), scheduleType: Type.Union([ Type.Literal('interval'), @@ -131,34 +127,40 @@ export default async function scheduleRoutes(app: FastifyInstance) { }); // PATCH /schedules/:taskId — update a scheduled task - app.patch('/:taskId', { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, async (request) => { - const { orgId, serverId, taskId } = request.params as { - orgId: string; - serverId: string; - taskId: string; - }; - await requirePermission(request, orgId, 'schedule.manage'); + app.patch( + '/:taskId', + { schema: { ...TaskParamSchema, body: UpdateScheduleBody } }, + async (request) => { + const { orgId, serverId, taskId } = request.params as { + orgId: string; + serverId: string; + taskId: string; + }; + await requirePermission(request, orgId, 'schedule.manage'); - const body = request.body as Record; + const body = request.body as Record; - const existing = await app.db.query.scheduledTasks.findFirst({ - where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)), - }); - if (!existing) throw AppError.notFound('Scheduled task not found'); + const existing = await app.db.query.scheduledTasks.findFirst({ + where: and(eq(scheduledTasks.id, taskId), eq(scheduledTasks.serverId, serverId)), + }); + if (!existing) throw AppError.notFound('Scheduled task not found'); - // Recompute next run if schedule changed - const scheduleType = (body.scheduleType as string) || existing.scheduleType; - const scheduleData = (body.scheduleData as Record) || (existing.scheduleData as Record); - const nextRun = computeNextRun(scheduleType, scheduleData); + // Recompute next run if schedule changed + const scheduleType = (body.scheduleType as string) || existing.scheduleType; + const scheduleData = + (body.scheduleData as Record) || + (existing.scheduleData as Record); + const nextRun = computeNextRun(scheduleType, scheduleData); - const [updated] = await app.db - .update(scheduledTasks) - .set({ ...body, nextRunAt: nextRun, updatedAt: new Date() }) - .where(eq(scheduledTasks.id, taskId)) - .returning(); + const [updated] = await app.db + .update(scheduledTasks) + .set({ ...body, nextRunAt: nextRun, updatedAt: new Date() }) + .where(eq(scheduledTasks.id, taskId)) + .returning(); - return updated; - }); + return updated; + }, + ); // DELETE /schedules/:taskId — delete a scheduled task app.delete('/:taskId', { schema: TaskParamSchema }, async (request, reply) => { @@ -223,7 +225,11 @@ export default async function scheduleRoutes(app: FastifyInstance) { }); } -async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{ +async function getServerContext( + app: FastifyInstance, + orgId: string, + serverId: string, +): Promise<{ serverUuid: string; node: DaemonNodeConnection; }> { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ac9cfd1..aeea7dc 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -76,62 +76,62 @@ function AuthGuard() { export function App() { return ( - - - - - {/* Public routes */} - } /> - } /> + + + + + {/* Public routes */} + } /> + } /> - {/* Protected routes */} - }> - }> - {/* Organizations */} - } /> + {/* Protected routes */} + }> + }> + {/* Organizations */} + } /> - {/* Org-scoped routes */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Org-scoped routes */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> - {/* Account */} - } /> + {/* Account */} + } /> - {/* Server detail */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Server detail */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Admin */} + } /> + } /> + } /> + } /> + } /> - - {/* Admin */} - } /> - } /> - } /> - } /> - } /> - - {/* Fallback */} - } /> - - - - - + {/* Fallback */} + } /> + + + + + ); } diff --git a/apps/web/src/components/layout/server-layout.tsx b/apps/web/src/components/layout/server-layout.tsx index 282812e..042393a 100644 --- a/apps/web/src/components/layout/server-layout.tsx +++ b/apps/web/src/components/layout/server-layout.tsx @@ -1,6 +1,16 @@ import { Outlet, useParams, Link, useLocation } from 'react-router'; import { useQuery } from '@tanstack/react-query'; -import { Terminal, FolderOpen, Settings, Calendar, HardDrive, Users, Puzzle, Settings2, Database as DatabaseIcon } from 'lucide-react'; +import { + Terminal, + FolderOpen, + Settings, + Calendar, + HardDrive, + Users, + Puzzle, + Settings2, + Database as DatabaseIcon, +} from 'lucide-react'; import { cn } from '@source/ui'; import { api } from '@/lib/api'; import { Badge } from '@/components/ui/badge'; @@ -52,9 +62,7 @@ export function ServerLayout() {

{server?.name ?? 'Loading...'}

- {server && ( - {server.status} - )} + {server && {server.status}}
{server && (

diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index b87a957..17d0238 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -96,8 +96,7 @@ function NavSection({ items, currentPath }: { items: NavItem[]; currentPath: str return (