From c1adb94abb1b35e3f9e6419164a01b12aa3ca586 Mon Sep 17 00:00:00 2001
From: hibna
Date: Sun, 2 Aug 2026 21:08:12 +0300
Subject: [PATCH] Format the repository with Prettier
CI has been running `prettier --check` against a tree that was never
formatted, so the check reported 63 files and failed every run. Nothing
here is a behaviour change: `pnpm lint` and the four typecheck builds
pass exactly as before.
conduit-bringup-artifacts is added to .prettierignore instead. Those
files are captured bring-up reports, not maintained sources; reflowing
them would only churn a record of what happened.
---
.prettierignore | 4 +
INSTALLATION.md | 104 ++--
README.md | 108 ++--
apps/api/src/index.ts | 70 +--
apps/api/src/lib/cdn.ts | 16 +-
apps/api/src/lib/daemon.ts | 10 +-
apps/api/src/lib/managed-config.ts | 24 +-
apps/api/src/lib/permissions.ts | 5 +-
apps/api/src/lib/schedule-utils.ts | 5 +-
apps/api/src/lib/server-automation.ts | 37 +-
apps/api/src/plugins/db.ts | 6 +-
apps/api/src/plugins/socket.ts | 17 +-
apps/api/src/routes/admin/index.ts | 526 +++++++++---------
apps/api/src/routes/admin/schemas.ts | 24 +-
apps/api/src/routes/games/index.ts | 5 +-
apps/api/src/routes/internal/index.ts | 40 +-
apps/api/src/routes/nodes/daemon.ts | 4 +-
apps/api/src/routes/nodes/index.ts | 84 +--
apps/api/src/routes/organizations/index.ts | 173 +++---
apps/api/src/routes/servers/backups.ts | 15 +-
apps/api/src/routes/servers/config.ts | 42 +-
apps/api/src/routes/servers/databases.ts | 328 +++++------
apps/api/src/routes/servers/files.ts | 14 +-
apps/api/src/routes/servers/players.ts | 6 +-
apps/api/src/routes/servers/plugins.ts | 332 +++++------
apps/api/src/routes/servers/schedules.ts | 64 ++-
apps/web/src/App.tsx | 98 ++--
.../src/components/layout/server-layout.tsx | 16 +-
apps/web/src/components/layout/sidebar.tsx | 3 +-
.../src/components/server/power-controls.tsx | 6 +-
apps/web/src/components/ui/badge.tsx | 3 +-
apps/web/src/components/ui/button.tsx | 10 +-
apps/web/src/components/ui/card.tsx | 16 +-
apps/web/src/components/ui/dialog.tsx | 11 +-
apps/web/src/components/ui/dropdown-menu.tsx | 6 +-
apps/web/src/components/ui/scroll-area.tsx | 6 +-
apps/web/src/lib/api.ts | 6 +-
apps/web/src/pages/admin/games.tsx | 12 +-
apps/web/src/pages/admin/nodes.tsx | 12 +-
apps/web/src/pages/admin/plugins.tsx | 98 ++--
apps/web/src/pages/auth/login.tsx | 13 +-
apps/web/src/pages/auth/register.tsx | 13 +-
apps/web/src/pages/dashboard/index.tsx | 4 +-
apps/web/src/pages/nodes/detail.tsx | 47 +-
apps/web/src/pages/nodes/index.tsx | 65 ++-
apps/web/src/pages/server/backups.tsx | 27 +-
apps/web/src/pages/server/config.tsx | 30 +-
apps/web/src/pages/server/databases.tsx | 26 +-
apps/web/src/pages/server/files.tsx | 46 +-
apps/web/src/pages/server/players.tsx | 4 +-
apps/web/src/pages/server/plugins.tsx | 27 +-
apps/web/src/pages/server/schedules.tsx | 62 ++-
apps/web/src/pages/server/settings.tsx | 80 ++-
apps/web/src/pages/settings/members.tsx | 19 +-
packages/database/src/migrate.ts | 14 +-
packages/database/src/schema/plugins.ts | 17 +-
packages/database/src/schema/schedules.ts | 7 +-
.../database/src/schema/server-databases.ts | 9 +-
packages/proto/src/index.ts | 4 +-
59 files changed, 1516 insertions(+), 1364 deletions(-)
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 (
@@ -241,7 +239,10 @@ export function AdminGamesPage() {
{game.dockerImage}
Port: {game.defaultPort}
- Automation: {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow
+
+ Automation:{' '}
+ {Array.isArray(game.automationRules) ? game.automationRules.length : 0} workflow
+
- Supported events: server.created, server.install.completed, server.power.started, server.power.stopped
+ Supported events: server.created, server.install.completed, server.power.started,
+ server.power.stopped