This document is the authoritative, internally consistent source of truth for the MinePanel project: product vision, implemented behavior, accepted architecture, accepted implementation backlog, future proposals and open decisions. When this specification disagrees with any other repository document, this specification wins; when it disagrees with the code, the code is the immediate truth and the discrepancy is recorded here as a correction or backlog item.
Every feature statement below carries one of these status markers:
| Marker | Meaning |
|---|---|
[IMPLEMENTED] |
Verified in the current code, schema, migrations and tests in the current working tree/implementation state (or at a future explicitly named verified revision). |
[ACCEPTED] |
Approved target behavior or architecture that is not yet implemented; tracked in the backlog (§16). |
[PROPOSED] |
Future design that still requires validation or a product/architecture decision; phase-marked (§17). |
[CONTRADICTED] |
A previous specification or configuration claim disproved by current code; the observed current behavior is documented with its correction/backlog item. |
[DECISION REQUIRED] |
An unresolved choice that materially affects security, compatibility, product behavior or deployment. Silently picking one is forbidden; see the decision register (§19). |
Normative language, defined once and used consistently:
-
MUST — a required invariant or contract; violating it is a defect or a security hole.
-
SHOULD — a strong recommendation; a valid exception must be justified in the code or docs.
-
MAY — optional behavior; no default obligation either way.
-
Status: release candidate, not "production-ready". Phase 1 and the per-server authorization spine are implemented; this slice also ships the transactional setup bootstrap and protocol-1 capability discovery. Hosted-auth compatibility still requires the reserved PKCE fallback, and open decisions in §19 plus the remaining P0/P1 backlog must be resolved before production-ready status.
-
Last verified implementation state: the current working tree on the implementation branch. This document deliberately records no commit hash; no commit is authorized by the implementation task.
-
Version truth:
package.jsonsays1.0.0;PANEL_VERSIONdefaults to"1.0"; the Swagger fallback is"N/A"; CI pins1.0.0. This inconsistency is tracked as backlog item B-P2-6. -
License:
package.jsondeclares"license": "UNLICENSED"and"private": true; there is no LICENSE file and the project must not be described as open-source. Picking a license is owner decision D-11.
MinePanel is a self-hosted Minecraft server management panel. A single docker compose up on the operator's own host brings up the backend, PostgreSQL, Caddy (HTTPS) and the Minecraft container runtime. The backend manages PostgreSQL for state, controls Minecraft server containers through the Docker socket, and exposes a REST + WebSocket API.
Clients — implemented vs planned:
| Client | Repo | Tech | Status |
|---|---|---|---|
| Web dashboard (hosted PWA) | minepanel-pwa |
React 19 + Vite 7 + TypeScript + Tailwind 4 | [ACCEPTED] — separate repository exists as a discovery shell; hosted at app.minepanel.xyz; not part of this backend's compose file |
| Mobile app | minepanel-mobile |
KMP + Compose Multiplatform (iOS + Android) | [PROPOSED] — Phase 6 |
| Backend API | minepanel-backend (this repo) |
NestJS 11 + PostgreSQL | [IMPLEMENTED] |
The backend is client-agnostic. Role-based guards (ADMIN / MOD / USER) plus per-server access rules and MOD granular permissions enforce access at the API level.
Hosted multi-backend dashboard (app.minepanel.xyz) — [ACCEPTED] discovery shell with a protocol-1 backend contract. The hosted PWA connects to self-hosted backends using cross-origin HttpOnly CHIPS cookies where supported; PKCE authorization-code fallback remains reserved and is not implemented. Until that fallback ships, complete hosted-browser compatibility is not claimed. The supported deployment model remains same-origin (frontend served from the same domain as the backend, or a dev frontend on localhost:5173 with CORS_ORIGIN set).
Direct browser access from https://app.minepanel.xyz to LAN/private-network instances (RFC1918 addresses, .local hostnames, or other browser-untrusted origins) is not automatically guaranteed by the generic HTTPS multi-backend architecture: browsers apply stricter mixed-content and certificate rules to such origins. The intended hosted path is browser-trusted public HTTPS backend origins; private-network endpoints are a separate compatibility concern requiring validation.
Key design decisions (accepted):
- Self-hosted first: backend + database + MC servers run on the operator's machine. External calls are optional (Discord webhooks, Mojang UUID API, Hangar/Modrinth metadata in future phases).
- No external queue or cache: PostgreSQL is the only stateful dependency. No Redis, no BullMQ, no CDN.
- Not admin-only: regular players have a dedicated portal surface in the roadmap (access requests, player profile, notifications — Phase 6).
[ACCEPTED]the backend data mount is read-only; every future write feature (backups, file manager, plugins) must route through the write architecture decided in §10.4 (owner decision D-8).
Development phases (canonical numbering — used consistently everywhere):
- Phase 1 — Foundation (v1.0 RC): auth (JWT cookies, sessions, password change, 2FA, temp-password recovery), rate limiting, server lifecycle, Docker service, health, host metrics over WebSocket, admin user management, server access control (per-server authorization spine), Docker deployment.
[IMPLEMENTED]. - Phase 1.5 — Access Control + OAuth: Google/GitHub OAuth, magic links (SMTP optional), invitation flows, MOD permission dashboard refinement.
[PROPOSED]. - Phase 2 — Developer platform: audit log, API keys, outbound webhooks, system events, historical metrics.
[PROPOSED]. - Phase 3 — Operations: WebSocket real-time server events, console, backups, scheduled tasks, notifications, file manager, player management, plugin management.
[PROPOSED]. - Phase 4 — Creation wizard & presets: preset-driven server creation, mod picker, template clone.
[PROPOSED]. - Phase 5 — Networking: Velocity proxy, Bedrock support.
[PROPOSED]. - Phase 6 — Mobile app & player portal.
[PROPOSED].
Operator runs: docker compose up -d
┌──────────────────────────────────────────────────────────────┐
│ Docker Host │
│ │
│ ┌───────────────┐ app network (minepanel-app-network) │
│ │ caddy │──80/443 (only published ports)──► Internet│
│ │ (TLS, HTTPS) │ │
│ └───────┬───────┘ │
│ │ reverse_proxy nestjs:3000 │
│ ┌───────▼───────┐ │
│ │ minepanel- │ expose 3000 (no host port); user: root; │
│ │ nestjs │ no-new-privileges; :ro data mount; │
│ │ (Bun runtime) │ docker socket mounted; NOT on mc network │
│ └──┬──────┬─────┘ │
│ │ │ │
│ │ └── Docker socket: DOCKER_SOCKET → /var/run/ │
│ │ docker.sock (rootful default; rootless via │
│ │ DOCKER_SOCKET override — see §10.2) │
│ ▼ │
│ ┌───────────────┐ app network │
│ │ postgres:16 │ (no published ports) │
│ └───────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐│
│ │ mc network (minepanel_network / DOCKER_NETWORK) ││
│ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ ││
│ │ │ mc-{id}-1 │ │ mc-{id}-2 │ │ mc-{id}-N │ ││
│ │ │ itzg image │ │ port 2556x │ │ :/data bind│ ││
│ │ └────────────┘ └────────────┘ └────────────┘ ││
│ └───────────────────────────────────────────────────────────┘│
│ (created by the backend via the Docker socket — the backend │
│ itself has no mc-network membership; RCON uses docker exec) │
└──────────────────────────────────────────────────────────────┘
Compose services (docker-compose.yml):
| Service | Image | Notes |
|---|---|---|
nestjs |
$MINEPANEL_IMAGE (default ghcr.io/minepanelproject/minepanel-backend:latest) |
expose: 3000 only; user: root; security_opt: no-new-privileges; healthcheck curl /health; depends on healthy postgres and completed minecraft-image prefetch |
postgres |
postgres:16-alpine |
volume postgres-data; healthcheck pg_isready; no published ports |
caddy |
caddy:2-alpine |
publishes 80/443 (+443/udp); auto-HTTPS from $DOMAIN; proxies to nestjs:3000; serves ./Caddyfile |
minecraft-image |
itzg/minecraft-server:latest |
one-shot prefetch (entrypoint: ["/bin/true"]) so the first server create does not stall on a pull |
- Internet ↔ Caddy. TLS termination and the only published ports (80/443). The backend is never published;
postgrespublishes nothing. Publishing backend port 3000 directly breaks thetrust proxy = 1assumption (main.ts) —X-Forwarded-Forbecomes spoofable, which defeats per-IP throttling and the CSRF same-origin check. Deployment docs MUST forbid it (§13). - Caddy ↔ backend. Plaintext HTTP on the
appbridge; single-host assumption; backend setstrust proxy = 1(main.ts). - Backend ↔ postgres. Password auth, no TLS, app-network only — acceptable single-host.
- Backend ↔ Docker daemon (crown jewels). The mounted socket gives the backend root-equivalent capability on the host. The container-creation guardrails (§10.3) are normative defense-in-depth.
DOCKER_SOCKETis a local Unix socket path only —tcp://endpoints are rejected at production preflight (main.ts). - MC containers. Untrusted, modded game code. They run unprivileged, memory-capped, with no added Linux capabilities, on a bridge network. Known gap (backlog B-P2-4): the
mcbridge allows unrestricted container-to-container traffic; per-server networks are[PROPOSED]. - Data volume. Host directory owned via daemon binds; itzg entrypoint chowns to its runtime user at container start; the backend reads it
:ro.
- B-P1-10: add
NanoCpusCPU quota andPidsLimitto MC containerHostConfig(one MC server can currently starve the backend/postgres; fork-bomb surface). - B-P2-4: document/restrict inter-container traffic on the
mcnetwork; per-server networks[PROPOSED]. - B-P2-5: run the backend as a non-root user with
group_addfor the docker group instead ofuser: root. - B-P2-6:
cap_drop: [ALL]+read_only: true+tmpfs: /tmpfor the backend container. - B-P2-7: pin the itzg image (digest or explicit tag) in compose and record the resolved digest per server.
These invariants are non-negotiable; future work MUST preserve them.
- Single authoritative schema. All tables and enums live in
src/db/schema.ts; migrations are generated from it and MUST stay in sync (§6). - Access and refresh session tokens only in HttpOnly cookies for web clients.
PublicUserresponses carry no session tokens. The 2FApre-authchallenge is the narrow exception: it is a five-minute response-body Bearer credential that MUST authorize onlyPOST /api/auth/2fa/verify. Any future bearer/ticket mechanism (§8.5, §8.6) MUST preserve the cookie-only session-token path. - Refresh tokens are server-revocable and rotated. Rotation MUST become atomic (see §8.3); replay of a consumed token MUST yield 401.
- Lifecycle transitions are compare-and-swap on status. Every server state change runs through a CAS update; concurrent conflicting operations fail with 409 (§11.2).
- Resource admission has operation-specific ordering. Create and start admission occurs before their state mutation. Restart performs its graceful stop first, then admission; a 422 admission failure leaves the target STOPPED (§11.4).
- Docker unavailability degrades without silent state claims. Read paths keep working; initial daemon failures return 503. A lifecycle operation whose daemon outcome is uncertain MAY settle its own row as
ERROR(§10.5, §11.1). - Setup creates exactly one administrator — the secure bootstrap invariant (§8.1) — under any concurrency and failure pattern.
- The backend data mount stays read-only until the write architecture decision (D-8) lands (§10.4).
- At least one active ADMIN always exists (advisory-lock serialized last-admin guard, §8.7).
- Fail closed on authorization/DB uncertainty: guards reject when the database cannot confirm status/role/permission (§8.4).
Six tables, all defined in src/db/schema.ts; migrations 0000–0003 in drizzle/ cumulatively match the schema exactly (no drift). Primary keys are text UUIDs generated by crypto.randomUUID() — not cuid.
users
| Column | Type | Constraints |
|---|---|---|
id |
text | PK, UUID |
email |
varchar(254) | not null, unique (case-sensitive) |
username |
varchar(32) | not null, unique (case-sensitive) |
passwordHash |
text | not null (OAuth-only accounts are a Phase 1.5 schema change; see §17.1) |
role |
enum ADMIN|MOD|USER |
default USER |
status |
enum ACTIVE|PENDING|BANNED |
default ACTIVE |
totpSecret |
text | null; AES-256-GCM encrypted |
totpEnabled |
boolean | default false |
totpBackupCodes |
text | null; JSON array of bcrypt-hashed backup codes |
tempPasswordHash |
text | null; admin-generated recovery credential |
tempPasswordExpiresAt |
timestamptz | null |
mustChangePassword |
boolean | default false |
minecraftUUID |
text | unique, null — column exists, no code path writes it today |
minecraftName |
text | null — column exists, no code path writes it today |
createdAt / updatedAt |
timestamptz | default now; updatedAt auto-updates |
refresh_tokens
| Column | Type | Constraints |
|---|---|---|
id |
text | PK, UUID |
token |
text | not null, unique — the bcrypt hash of the refresh JWT |
userId |
text | not null, FK → users, onDelete: cascade |
expiresAt |
timestamptz | not null |
createdAt |
timestamptz | default now |
There are no
userAgent/lastUsedAtcolumns; the old SPEC claimed both. Session metadata is an accepted backlog item (§8.3). No index exists onuserIdorexpiresAt(backlog B-P1-3).
setup_state — singleton row (id = 'singleton'): initialAdminCreated boolean, createdAt, updatedAt.
servers
| Column | Type | Constraints |
|---|---|---|
id |
text | PK, UUID |
name |
text | not null |
provider |
enum VANILLA|PAPER|PURPUR|FABRIC|FORGE |
not null |
version |
text | not null |
port |
integer | not null, unique |
containerId |
text | unique, null until provisioned |
status |
enum STOPPED|CREATING|STARTING|RUNNING|STOPPING|ERROR |
default STOPPED |
maxPlayers |
integer | default 20 |
difficulty |
enum PEACEFUL|EASY|NORMAL|HARD |
default NORMAL |
gamemode |
enum SURVIVAL|CREATIVE|ADVENTURE|SPECTATOR |
default SURVIVAL |
pvp |
boolean | default true |
memoryLimitMb |
integer | default 2048 |
motd |
text | null |
levelSeed |
text | null |
onlineMode |
boolean | default true |
viewDistance |
integer | default 10 |
allowFlight |
boolean | default false |
worldPath |
text | null — unused by code |
rconPassword |
text | null — column exists but is not written (backlog B-P1-11) |
ownerId |
text | not null, FK → users (creator) |
accessType |
enum OPEN|REQUEST|PRIVATE |
default OPEN |
createdAt / updatedAt |
timestamptz |
No
pendingDeleteAtcolumn exists (the old SPEC's deletion design was never built; §11.6) and nodiscordWebhookcolumn exists (Phase 3 notification feature).
server_access — join table: userId FK cascade, serverId FK cascade, status enum PENDING|APPROVED, createdAt, approvedAt. Constraints: unique (userId, serverId); CHECK (status = 'PENDING' AND approvedAt IS NULL) OR (status = 'APPROVED' AND approvedAt IS NOT NULL); indexes on (serverId, status, createdAt, id) and (userId, serverId). There is no DENIED status — rejection is represented by deleting the row.
mod_permissions — userId FK cascade, permission enum (SERVER_LIFECYCLE, SERVER_CONFIG, PLUGIN_MANAGEMENT, WHITELIST_MANAGEMENT, USER_MANAGEMENT, FILE_MANAGER), serverId nullable FK cascade, createdAt. Partial unique index on (userId, permission) where serverId IS NULL; unique on (userId, permission, serverId); index on (userId, permission, serverId).
The old SPEC listed these as current models. They are future work, defined in §17: Ban, MagicLinkToken, ApiKey, Webhook, SystemEvent, AuditLog, MetricSnapshot, Backup, ScheduledTask, Notification, ServerPlugin, ServerMod, ServerProxy. users.googleId, users.githubId, users.minecraftVerified, servers.discordWebhook are future columns. users.passwordHash MUST become nullable for Phase 1.5 OAuth (§17.1).
role, user_status, server_provider, server_status, server_difficulty, server_gamemode, access_type, server_access_status, mod_permission — exact values in §6.1.
Global prefix api (except /health); Swagger UI at /docs (public — backlog B-P3-3 to gate it). All routes below are [IMPLEMENTED]; [PROPOSED] endpoints live in §17 and are never mixed into these tables.
| Method | Path | Description |
|---|---|---|
| GET | /health |
Liveness: { status: 'ok'|'degraded', db, docker, version }; 503 when degraded. Uses SELECT 1 + docker.ping(). |
| GET | /api/info |
Protocol-1 capability discovery: { name, version, api: { protocolVersion: 1 }, capabilities: { auth: { partitionedCookies: true, pkceAuthorizationCode: false }, realtime: { websocketTicket: false } } }; sends Cache-Control: no-store. |
| GET | /docs |
Swagger UI (public in current builds) |
Capability discovery [IMPLEMENTED] — GET /api/info is the version-independent protocol contract for hosted clients. partitionedCookies: true means the production auth cookie paths emit CHIPS Partitioned; PKCE authorization-code fallback and WebSocket tickets remain false until their future implementations ship. Clients MUST use these flags rather than infer compatibility from version.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/setup/status |
Public | { initialAdminCreated, nextStep: 'register_admin'|'complete' } |
| POST | /api/setup/init |
Public, X-Setup-Token, throttle 5/10 min per IP |
Create first admin atomically; missing or wrong token returns 401 SetupTokenInvalid; a valid token after completion returns 409 SetupAlreadyComplete. SETUP_TOKEN is used verbatim when configured; otherwise a base64url token is generated once per incomplete process and logged once. |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/auth/register |
Public, throttle 5/10s | 201 { message }; PENDING if REQUIRE_ADMIN_APPROVAL=true, else ACTIVE; 409 User already exists |
| POST | /api/auth/login |
Public, throttle 5/10s | 200 PublicUser + cookies, or { requiresTwoFactor, preAuthToken }; 401 Wrong credentials (timing-equalized); 403 AccountPending/AccountBanned |
| POST | /api/auth/refresh |
Public, throttle 5/10s | Rotates refresh token, sets new cookies. Missing/malformed/expired refresh currently → 500 (bug, B-P1-2) |
| POST | /api/auth/logout |
JWT | Revokes the presented refresh row, clears cookies |
| POST | /api/auth/logout-all |
JWT | Revokes all refresh rows, clears cookies |
| GET | /api/auth/profile |
JWT | Current user (req.user shape) |
| PATCH | /api/auth/profile |
JWT | Update username only (email is not editable through any endpoint today); 400 No changes when identical |
| PATCH | /api/auth/password |
JWT | Change password; requires currentPassword; keeps current session, revokes others (normal flow) or all (forced recovery flow) |
| GET | /api/auth/sessions |
JWT | List refresh-token rows (id, userId, expiresAt, createdAt) — currently includes expired rows (B-P1-3) |
| DELETE | /api/auth/sessions/:id |
JWT | Revoke own session; silently succeeds for missing rows |
| POST | /api/auth/2fa/setup |
JWT | Returns { secret, uri }; secret encrypted at rest |
| POST | /api/auth/2fa/confirm |
JWT | Verifies first code, enables 2FA, returns 8 single-use backup codes |
| POST | /api/auth/2fa/verify |
Pre-auth Bearer, throttle 5/600s | Completes 2FA login, sets cookies |
| DELETE | /api/auth/2fa/disable |
JWT | Requires valid TOTP or backup code |
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /api/admin/users |
ADMIN | List, filter by status/role |
| PATCH | /api/admin/users/:id/status |
ADMIN | Approve/ban/unban; last-active-admin guard; ban deletes all refresh sessions |
| PATCH | /api/admin/users/:id/role |
ADMIN | Change role; last-active-admin guard; clears mod permissions |
| POST | /api/admin/users/:id/reset-password |
ADMIN | Returns one-time { tempPassword } (16 chars base64url, 24h TTL, forces change, revokes all sessions) |
| DELETE | /api/admin/users/:id/2fa |
ADMIN | Emergency 2FA removal |
| GET | /api/admin/users/:id/permissions |
ADMIN | List MOD permission grants |
| POST | /api/admin/users/:id/permissions |
ADMIN | Grant (global or per-server); 409 on duplicate |
| DELETE | /api/admin/users/:id/permissions/:permId |
ADMIN | Revoke; 404 if missing |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/servers |
ADMIN | Create and start in one operation; 201; resource checks 422; rolls back on Docker failure |
| GET | /api/servers |
JWT | List visible servers; ?limit&offset (default 20, max 100); { data, total } |
| GET | /api/servers/:id |
JWT | Get visible server; 404 for non-visible |
| POST | /api/servers/:id/start |
ADMIN | MOD + SERVER_LIFECYCLE |
409 unless STOPPED; container must exist |
| POST | /api/servers/:id/stop |
ADMIN | MOD + SERVER_LIFECYCLE |
Graceful stop (RCON warn → save-all → docker stop) |
| POST | /api/servers/:id/restart |
ADMIN | MOD + SERVER_LIFECYCLE |
Stop sequence then start sequence |
| DELETE | /api/servers/:id |
ADMIN | Returns 202 but is fully synchronous: removes container and DB row; data directory is retained on the host (§11.6) |
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/servers/:id/request-access |
JWT | REQUEST servers: create PENDING row (201); OPEN → 409; PRIVATE → 404 (non-disclosure); ADMIN → 409 |
| GET | /api/servers/:id/my-access-request |
JWT | Own row ({ status, requestedAt, approvedAt }); 404 for OPEN/PRIVATE/none |
| GET | /api/servers/:id/access-requests |
ADMIN | Pending requests for REQUEST servers |
| POST | /api/servers/:id/access-requests/:userId/approve |
ADMIN | PENDING → APPROVED, or direct APPROVED insert (PRIVATE/REQUEST); ADMIN target → 400 |
| DELETE | /api/servers/:id/access-requests/:userId |
ADMIN | Reject or revoke (row deleted; no DENIED status) |
- Default namespace (
/), socket.io v4, CORS locked to the canonical origin (adapterallowRequest: handshakes carrying an Origin must match exactly; header size limits). - Auth:
access_tokencookie in the handshake, or oneauthevent{ accessToken }within 5 seconds; otherwise silent disconnect. Reservation cap 100 pending connections. - Eligibility: ADMIN only, excluding temporary-auth sessions (
mustChangePassword). - Events: one event today —
system.stats{ totalRamMb, usedRamMb, freeDiskMb, cpuCount }every 10s (volatile, cached ≤10s), token re-validated each tick.usedRamMb = hostTotal − containerFree(container cgroup free memory; documented caveat, B-P2-8). - Contradiction to fix: JS cannot read the HttpOnly access token, so the
auth-event fallback is unusable by browsers; and the adapter rejects cookie-carrying handshakes without an Origin (mobile clients). Accepted fix: one-time WS ticket (B-P1-4, §8.6). The richer event set in the old SPEC (server.status/log/console, subscribe) is[PROPOSED]Phase 3 (§17.3).
POST /api/setup/init now requires the one-time X-Setup-Token header before hashing or touching the database. Missing and wrong headers are indistinguishable 401 SetupTokenInvalid responses; after setup completes, a valid token receives 409 SetupAlreadyComplete.
The write path is one transaction under pg_advisory_xact_lock(7330): it re-reads setup_state, inserts the ADMIN row, and sets initialAdminCreated; any failure rolls back both writes. The route is throttled to 5 attempts per 10 minutes per IP. A configured non-empty SETUP_TOKEN is used verbatim and never logged; when it is absent, a 24-byte base64url token is generated once per incomplete process and emitted once in the service log for operator retrieval. Generated tokens are process-local and change on restart.
This closes the former concurrent-admin and insert/flag failure races. D-2 is adopted: setup authorization is mandatory.
- Cookie names:
access_token(15 min TTL),refresh_token(7 days). Both areHttpOnly, explicitlyPath=/, and useSecure; SameSite=None; Partitionedin production. Development usesHttpOnly; Path=/; SameSite=Laxand omitsSecure/Partitioned. - Access JWT:
{ sub, type: 'access', username, role, temporaryAuth? }— TTL fromJWT_EXPIRES_INviaJwtModulesignOptions. - Refresh JWT:
{ sub, type: 'refresh', temporaryAuth? }— hardcoded7d;JWT_REFRESH_EXPIRES_INis declared but never read (B-P1-5: consume it or delete it). Stored bcrypt-hashed inrefresh_tokens. typeclaims pin token purpose: onlytype: 'refresh'may rotate; onlytype: 'access'passes the JWT guard;pre-authis a five-minute response-body Bearer token restricted toPOST /api/auth/2fa/verify.- Login and refresh return
PublicUserplus session cookies. A 2FA-required login instead returns{ requiresTwoFactor: true, preAuthToken }without setting session cookies; the browser-visible pre-auth exception is scoped in §5.2.
Current implementation rotates on every refresh (no 24h sliding rule — the old SPEC and docs/auth-architecture.md are wrong on this), but consumption is delete-then-insert without a transaction: two concurrent refreshes with the same token can both mint successors → two live sessions from one token. Replay of an already-rotated token does 401. Missing/malformed/expired refresh → 500 Internal server error (JWT errors fall through the exception filter catch-all; B-P1-2).
Accepted contract (backlog B-P1-1):
- Atomic rotation in one transaction:
SELECT … FOR UPDATE→ bcrypt-compare →DELETE→INSERTsuccessor → commit; the losing concurrent request sees no row and gets 401. - Add the refresh row id as
jtiso lookup is O(1) instead of the current O(n) bcrypt fan-out over all the user's tokens (a self-DoS amplifier under the 5/10s throttle). - All refresh failures → 401 with a stable machine code:
RefreshTokenMissing(no cookie),RefreshTokenMalformed,RefreshTokenExpired,TokenWrongPurpose,RefreshTokenInvalid. Never 500. - Expiry synchronization: consume
JWT_REFRESH_EXPIRES_INonce at boot and derive JWTexpiresIn, DBexpiresAtand cookiemaxAgefrom it; remove the hardcoded7dliterals. - Cleanup: lazy per-user sweep inside the rotation transaction + a daily in-process sweep of expired rows (no scheduler dependency exists yet — see §16 B-P1-6 for the
@nestjs/scheduledecision). GET /sessionsMUST filterexpiresAt > now().lastUsedAt/userAgentcolumns are[ACCEPTED].- Indexes:
refresh_tokens(user_id)andrefresh_tokens(expires_at)(B-P1-3). - Frontend MUST single-flight
/auth/refresh(two racing tabs ⇒ one tab's cookie is dead by design under strict rotation). - Theft detection (revoke a whole token family on reuse) is
[PROPOSED], not built.
Global order in app.module.ts:
CsrfOriginGuard— mutating requests carrying anOriginheader must match the canonicalCORS_ORIGIN(or the API's own origin, e.g. Swagger); mismatch/null/repeated → 403{ error: 'CsrfOriginForbidden' }. No-Origin requests (curl/CI) pass. Socket.IO is intercepted by the adapter before Nest routing.JwtAuthGuard— readsaccess_tokencookie;@Public()bypasses. Verifies viaAccessTokenService(JWT verify + DB-freshstatus/role/mustChangePassword; fails closed on DB error; PENDING → 403AccountPending, BANNED → 403AccountBanned). Forced-recovery sessions are allowed only onPATCH /api/auth/password, else 403PasswordChangeRequired.RolesGuard—@Roles('ADMIN', …); ADMIN always passes; route without roles passes.PermissionsGuard—@RequiresPermission('SERVER_LIFECYCLE'); ADMIN passes; MOD must have amod_permissionsrow (globalserverId IS NULLor scoped to:id); fails closed 503Permission check unavailableon DB error.ThrottlerGuard— see §13.
Known nuance (B-P3-4): a MOD with a global SERVER_LIFECYCLE grant can pass the guard but still get 404 on a PRIVATE server without an approved server_access row (visibility is separate from action permission).
The hosted dashboard (minepanel-pwa) uses protocol-1 capability discovery. CHIPS Partitioned HttpOnly cookies are the primary cross-origin session mechanism where the browser supports them; the backend emits SameSite=None; Secure; Partitioned in production. The PKCE authorization-code flow with a memory-only bearer access token is the documented fallback design, but it is reserved and not implemented. Therefore complete hosted-browser compatibility remains a release blocker; same-origin deployment is fully supported.
GET /api/info truthfully advertises partitionedCookies: true, pkceAuthorizationCode: false, and websocketTicket: false. Clients MUST NOT infer support from panel version strings. The previous claim that SameSite=None; Secure alone is sufficient is obsolete.
Accepted design: POST /api/realtime/ticket (authenticated, throttled) → single-use ticket, 60s TTL, server-bound to {userId, role, exp}, consumed atomically on connect; the gateway keeps its per-tick user re-validation. The cookie handshake remains the fast path. This resolves the current contradiction (§7.7) for browsers (which cannot read the HttpOnly access token) and for mobile clients (which send no Origin). Whether cookie or ticket is primary follows D-1.
pg_advisory_xact_lock(7331) serializes role/status transitions; deactivating or demoting the last active ADMIN → 409 Cannot deactivate the last active admin. Banning deletes all refresh sessions (the old SPEC claimed tokens are kept on ban — false; unban requires re-login). Role change clears all mod_permissions rows. Admin password reset: 16-char base64url temp password, 24h TTL, mustChangePassword=true, revokes all sessions, returned plaintext once.
- Registration: email is trimmed+lowercased; username is trimmed but case-preserved; uniqueness is case-sensitive (Postgres default) —
Bobandbobare two accounts. - Login: the identifier is trimmed+lowercased and matched exactly against email OR username.
- Consequence: a username containing any uppercase letter cannot be used to log in by username (the lowercased identifier never matches the stored casing); email login still works. Documented defect; accepted fix = normalize at registration (lowercase) or case-insensitive lookup — owner decision folded into D-10.
TOTP (RFC 6238, otplib), ±30s window; secret AES-256-GCM encrypted with ENCRYPTION_KEY. Login with 2FA returns a 5-minute pre-auth Bearer token ({ type: 'pre-auth', sub }; temporary-recovery variants carry a SHA-256 fingerprint of the temp hash); 2fa/verify consumes it. In-memory per-user lockout: 5 failures per 10 min → 15 min 429 (lost on restart — accepted, single-instance; B-P3-5). Backup codes: 8 × 8hex-8hex, bcrypt-hashed, JSON-array stored, single-use via atomic CAS on the stored array. Admin emergency disable clears secret+enabled+backup codes in one update.
ADMIN bypasses role and permission checks. MOD needs explicit mod_permissions grants for privileged actions. USER is a panel account without moderation powers. Roles are read from the DB on every request (never trusted from the token alone).
A server row is visible to a caller when: caller is ADMIN (any non-CREATING row), or accessType = 'OPEN', or an APPROVED server_access row exists for the caller. Non-visible servers are indistinguishable from missing (404 — no disclosure). ownerId records the creator but confers no special visibility; there is no owner concept in the access model.
OPEN: visible to all authenticated users; no row needed.REQUEST: user POSTsrequest-access→PENDINGrow; ADMIN approves →APPROVED. Duplicate PENDING/APPROVED → 409; race-safe via unique constraint + on-conflict + bounded retry.PRIVATE: no request flow; ADMIN assigns directly (approve endpoint inserts anAPPROVEDrow). Users see 404.- Revocation deletes the row (no
DENIEDstatus).
Six permission values (SERVER_LIFECYCLE, SERVER_CONFIG, PLUGIN_MANAGEMENT, WHITELIST_MANAGEMENT, USER_MANAGEMENT, FILE_MANAGER). Grants are global (serverId IS NULL, partial-unique) or per-server. Enforcement: PermissionsGuard on @RequiresPermission routes — today SERVER_LIFECYCLE on start/stop/restart. SERVER_CONFIG/PLUGIN_MANAGEMENT/etc. become live as their Phase 3 endpoints land (§17).
DockerModule builds a Dockerode client over a local Unix socket only: DOCKER_SOCKET (default /var/run/docker.sock inside the container), with the ambient DOCKER_HOST env suppressed. If the daemon is unreachable at startup the module logs and continues in degraded mode (health reports 503, Docker operations throw 503) — startup never fails because Docker is down.
| Variable | View | Read by | Purpose |
|---|---|---|---|
MC_DATA_PATH_HOST |
Host filesystem | compose | The real host directory (wizards default $HOME/.minepanel/mc-data). Compose passes it to MC_DATA_BIND_SOURCE and binds it into the backend at /mc-data read-only |
MC_DATA_BIND_SOURCE |
Host filesystem | Docker daemon | Bind source for every MC container: {MC_DATA_BIND_SOURCE}/{serverId}:/data — passed verbatim, never normalized (a Windows path like C:\… must reach the daemon unchanged; the daemon resolves it) |
MC_DATA_PATH |
Backend container | backend | Fixed /mc-data in compose; validated absolute, no ./.. segments; used exclusively for statfs disk admission and reads |
Because a bind mount exposes the same filesystem, statfs(/mc-data) measures the physical data root the MC containers actually write to — this is the correct physical root for disk admission. On Docker Desktop it measures the VM's mount of the host drive (acceptable approximation, documented).
Invariants (testable): (1) after POST /servers, $MC_DATA_PATH_HOST/{serverId} exists on the host; (2) MIN_FREE_DISK_MB above actual free space ⇒ create/start fails 422; (3) writes via the container view always appear in the host view.
Socket default truth: the shipped compose default is rootful Docker (${DOCKER_SOCKET:-/var/run/docker.sock}). Rootless Docker requires setting host DOCKER_SOCKET=/run/user/$UID/docker.sock (or Podman: /run/user/$UID/podman/podman.sock). The old SPEC's "rootless is the default, zero-touch, ${XDG_RUNTIME_DIR}" claims are false against the compose file. Keeping the rootful default is owner decision D-4 (recommendation: keep — NAS/VPS compatibility); rootless remains a documented override. tcp:// endpoints are rejected (local-socket-only contract).
DockerService.createContainer() builds exactly (never user-driven):
- Image
itzg/minecraft-server; namemc-{serverId}; labelsminepanel.server-id,minepanel.managed=true. - Env whitelist (nothing else):
EULA=TRUE,ENABLE_RCON=TRUE,TYPE=<provider>,VERSION,MEMORY={n}M,MAX_PLAYERS,DIFFICULTY,MODE(itzg uses MODE, not GAMEMODE),ONLINE_MODE,VIEW_DISTANCE,ALLOW_FLIGHT,PVP,MOTD(CR/LF stripped),SEED. - Binds
{MC_DATA_BIND_SOURCE}/{serverId}:/data; port mapping25565/tcp→server.portwithinMC_PORT_MIN–MC_PORT_MAX;Memory=memoryLimitMbbytes (min 512);Privileged: false;CapAdd: [];NetworkMode=DOCKER_NETWORK(must be a named network —host/none/container:rejected);RestartPolicy: unless-stopped. - Missing CPU/pids limits: backlog B-P1-10. Untagged image: backlog B-P2-7.
RCON today = docker exec rcon-cli (validated argv: no NUL/CR/LF, ≤2 args, ≤total bytes, hard timeout), used by the graceful-stop sequence. There is no TCP RCON service and rconPassword is never written (B-P1-11: generate a per-server password at create, store it AES-GCM-encrypted, and pass it as RCON_PASSWORD).
The backend mount is :ro today; future features (backups, restore, plugins, file manager, icons, config generation) need writes. Options: (a) read-write mount (simplest; any traversal/logic bug rewrites world data); (b) docker exec/cp per op (needs running containers; daemon-side root writes; fragile as primary); (c) recommended — narrow filesystem-helper sidecar: data root mounted rw, fixed UID 1000:1000, no docker socket, no published ports, own bearer credential, and the §18.2 path-safety module implemented once inside it; (d) per-operation temporary containers — recommended for restore/extract only. Until D-8 lands, the backend MUST stay read-only for data paths.
Daemon-unreachable transport and 502/503/504 responses surface as 503 { message: 'Docker daemon unreachable' } at the Docker boundary. Read paths (GET /servers, GET /servers/:id) keep working; WS system.stats stops emitting; startup reconciliation leaves lifecycle rows untouched when the daemon is unavailable. A lifecycle operation that already claimed a transition before the daemon becomes unreachable settles that row as ERROR, rather than asserting an unverified prior state (§11.1).
create+start (ADMIN)
STOPPED ───────────────► CREATING ──► RUNNING (create failure → rollback/ERROR)
STOPPED ── start ──► STARTING ──► RUNNING
RUNNING ── stop ──► STOPPING ──► STOPPED
RUNNING ── restart ──► STOPPING ──► STARTING ──► RUNNING
any ── reconcile ──► STOPPED/RUNNING (truth from Docker inspect)
STOPPED ── delete ──► STOPPING ──► (container removed, DB row deleted)
Every transition is a CAS UPDATE … WHERE status = <expected> (plus containerId when set) — the losing request gets 0 rows → 409 Server is not in X state. pg_advisory_xact_lock(7332) serializes admission checks and create/start/restart claims. Error classification after a Docker failure is truthful: an in-flight lifecycle operation with a daemon-unreachable outcome → ERROR; container not found → STOPPED; known daemon rejection → prior state restored.
| Operation | Required status | On mismatch |
|---|---|---|
| start | STOPPED (and container provisioned) | 409 |
| stop | RUNNING | 409 |
| restart | RUNNING | 409 |
| delete | STOPPED | 409 |
Disk check → transaction (advisory lock; memory admission summing all servers incl. stopped) → insert CREATING → docker createContainer → CAS-claim containerId → docker start → CREATING → RUNNING. Failures: create fails → compensation (delete row, or mark ERROR if a managed container was found); start fails → ERROR. No orphan DB rows; a crash mid-create is settled by startup reconciliation.
- Create:
freeDiskMb ≥ MIN_FREE_DISK_MB(statfs on/mc-data) andsum(all memoryLimitMb) + requested ≤ totalRamMb × MAX_MEMORY_RATIO(docker.info). Sum of ALL servers (stopped servers consume disk and will consume RAM). - Start: same disk check; memory sums non-
STOPPEDservers, excluding the target. - Restart: graceful stop occurs first, then the same disk and memory admission runs. A 422 admission failure after that stop leaves the target STOPPED.
- Failure envelope:
422 { statusCode, error: 'InsufficientResources', message, details: { resource: 'disk'|'memory', availableMb, requiredMb, totalMb } }. Unavailable host info → 503.
RUNNING → STOPPING → rcon-cli say '§cServer closing in N seconds…' → wait STOP_WARN_SECONDS (0–300) → rcon-cli save-all → 3s → docker stop (t: 15; t: 10 if RCON failed). RCON failure degrades to direct stop. Restart runs the full stop then the start sequence — never docker restart.
Current v1 behavior ([IMPLEMENTED]): requires STOPPED → CAS to STOPPING → docker remove(force:false) → delete DB row (with truthful reconciliation on failure) → HTTP 202 returned for a fully synchronous operation. The host data directory {MC_DATA_PATH_HOST}/{serverId} is never removed — no backup, no pendingDeleteAt, no cleanup job, no recovery window.
Retained-data contract: removing a server removes its container and panel registration; world data remains at <MC_DATA_PATH_HOST>/<serverId> and can be deleted manually. To remove it, an operator MUST provide the exact host root and server UUID; the guarded command below displays the target and refuses empty, non-absolute, malformed, or wildcard-like inputs. Deletion is irreversible and the command contains no wildcard:
: "${MC_DATA_PATH_HOST:?Set MC_DATA_PATH_HOST to the absolute host data root}"
: "${SERVER_UUID:?Set SERVER_UUID to the exact server UUID}"
case "$MC_DATA_PATH_HOST" in /*) ;; *) printf '%s\n' 'MC_DATA_PATH_HOST must be absolute' >&2; exit 1 ;; esac
if [[ ! "$SERVER_UUID" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[1-5][0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}$ ]]; then
printf '%s\n' 'SERVER_UUID must be a UUID' >&2
exit 1
fi
target="$MC_DATA_PATH_HOST/$SERVER_UUID"
printf 'Deletion target: %s\n' "$target"
printf '%s\n' 'WARNING: this permanently deletes retained Minecraft data and cannot be undone.'
read -r -p 'Type DELETE to continue: ' confirmation
[ "$confirmation" = DELETE ] || { printf '%s\n' 'Aborted.'; exit 1; }
rm -rf -- "$target"No runtime deletion behavior or HTTP status changes are implied by this manual procedure. Tombstone/backup/sweeper/restore behavior remains deferred.
onModuleInit inspects every non-STOPPED row: container inspect → RUNNING if running else STOPPED; container missing → managed-label lookup → STOPPED with containerId cleared; writes are CAS-guarded (status + containerId + updatedAt microsecond equality). Daemon unavailable → rows left untouched.
Four shapes coexist: NestJS default for HttpException; { message } only for PG errors via DbExceptionFilter (23505 → 409 Resource already exists, 23503 → 400, 42P01/42703 → 500, other → 500); structured { error: '…' } payloads on some 403s (AccountPending, AccountBanned, PasswordChangeRequired, CsrfOriginForbidden); { statusCode, error, message, details } for 422 resource errors. Non-HTTP errors (e.g. JWT library errors in refresh) → 500 Internal server error. No request-id anywhere.
{ "statusCode": 403, "error": "AccountPending", "message": "human text", "details": {}, "requestId": "uuid" }error= stable SCREAMING-case machine code; the frontend MUST switch onerror, nevermessage.detailsoptional structured context;requestIdechoed asX-Request-Idand logged with every line.- Mapping: 400 validation (class-validator array normalized into
details), 401/403 authN/Z, 404, 409 conflict, 413/422 domain, 503 docker/db unavailable, 500 generic (never leak internals). - Implementation: widen the filter into a global
AppExceptionFilter; fix the JWT-error→500 fall-through (with B-P1-2).
NODE_ENV=production boot fails fast unless: DATABASE_URL is a valid postgres URL; JWT_SECRET ≥ 32 chars and not the placeholder; JWT_EXPIRES_IN non-empty; ENCRYPTION_KEY is exactly 64 hex chars; DOCKER_SOCKET is an absolute socket path (no tcp://); CORS_ORIGIN is a single absolute origin (https, or loopback) with no path/query/credentials. Migrations run before the app listens (advisory-locked). There is no declarative config validation schema in ConfigModule — preflight is manual code (B-P2-10: consider a Joi/Zod schema).
Consumed [IMPLEMENTED]: DATABASE_URL, JWT_SECRET, JWT_EXPIRES_IN, ENCRYPTION_KEY, DOCKER_SOCKET, DOCKER_NETWORK, MC_DATA_PATH, MC_DATA_BIND_SOURCE, MC_PORT_MIN, MC_PORT_MAX, MIN_FREE_DISK_MB, MAX_MEMORY_RATIO, STOP_WARN_SECONDS, REQUIRE_ADMIN_APPROVAL, CORS_ORIGIN, PORT, PANEL_NAME, PANEL_DESCRIPTION, PANEL_VERSION, NODE_ENV (cookie/preflight behavior).
Declared but never read [CONTRADICTED]: JWT_REFRESH_EXPIRES_IN (refresh hardcoded 7d — B-P1-5), LOGIN_THROTTLE_LIMIT, LOGIN_THROTTLE_TTL_MS (dead — B-P1-9: wire or delete), SMTP_* (Phase 1.5), MICROSOFT_* (Phase 1.5). DOMAIN is consumed by Caddy only. PANEL_ASSETS_PATH is planned but unmounted — no /panel/logo endpoints exist; the old SPEC's static-assets section (server icons, panel logo) is [PROPOSED] with the Phase 3 write architecture (D-8). MC_DATA_PATH_HOST is compose-only (required by ${MC_DATA_PATH_HOST:?}).
The only inbound path is Caddy on the app network; trust proxy = 1 is set (main.ts). Publishing the backend port directly breaks throttling and the CSRF same-origin check — MUST NOT happen (§4.2). Caddyfile forces https://{$DOMAIN}; CORS_ORIGIN is never derived from DOMAIN and must be set explicitly.
~250 Jest specs colocated as *.spec.ts; DRIZZLE and DOCKERODE tokens always mocked; no unit test touches a live Postgres or Docker daemon; no live secrets are read. Coverage spans auth (login timing, refresh, 2FA, temp password), guards (jwt/roles/permissions/pre-auth/csrf), setup, servers (CAS transitions, reconciliation, admission), docker (container config, RCON validation, degraded mode), gateway (adapter, reservation, system metrics), admin (last-admin, grants), DTO validation.
test/ suites run against a live loopback Postgres (TEST_DATABASE_URL; CI spins a postgres:16 service) with env (JWT_SECRET, JWT_EXPIRES_IN, ENCRYPTION_KEY, CORS_ORIGIN, REQUIRE_ADMIN_APPROVAL=false). No e2e suite has Docker daemon access and none creates a real Minecraft container — the Docker service is always mocked. CI e2e job: migrations → test:e2e. The only daemon-touching smoke is the release-only publish job (health-200 with the runner's socket; PR builds never see a daemon — explicitly a trust boundary).
| Job | Runs | Gate |
|---|---|---|
test |
biome lint:ci, build, jest in-band | PR + master |
migration |
full db:migrate chain on a fresh Postgres |
PR + master |
e2e |
migrations + e2e on live PG (no daemon) | PR + master |
image |
build amd64, degraded-mode smoke (no socket), migration-before-listen check, image-content assertions, bcrypt load, Trivy CRITICAL + fixed-HIGH | PR + master |
publish |
trusted daemon smoke, multi-arch (amd64/arm64) GHCR push with SBOM/provenance; latest+sha+semver tags |
master push / v* tags |
Release gate status: the repository's configured CI gates were green at the last recorded verification; this working tree adds the setup/capability slice and must pass the mandatory gates before release.
- Setup race and throttling now have live-Postgres coverage in
test/setup-bootstrap.e2e-spec.ts; refresh rotation concurrency and retained-data delete semantics remain backlog coverage. - No real Docker lifecycle integration test (container create → run → graceful stop → delete → data retention).
[PROPOSED]: release-only job (mirrors the trustedpublishsmoke) that runs the full lifecycle against a real daemon before tagging. This is the behavior gap addressed by B-P1-13.
Verified in the current working tree. ✓ = implemented and tested as noted; (✓) = implemented, partial/indirect test coverage.
| Domain | Feature | Status | Evidence |
|---|---|---|---|
| Auth | register / login (timing-equalized, dummy hash) | ✓ | auth.service.ts, unit+e2e |
| Auth | HttpOnly cookie sessions (access 15m / refresh 7d) | ✓ | auth.controller.ts, e2e |
| Auth | refresh rotation (per-use; non-atomic — B-P1-1) | ✓ (defect) | auth.service.ts:278-286 |
| Auth | logout / logout-all / sessions list / revoke one | ✓ | auth.controller.ts, unit+e2e |
| Auth | password change (keep current session) | ✓ | users.service.ts, unit |
| Auth | forced recovery (admin temp password, mustChangePassword) | ✓ | auth.service.ts, jwt-auth.guard.ts, unit |
| Auth | TOTP 2FA: setup/confirm/verify/disable, backup codes, lockout | ✓ | unit+e2e |
| Auth | account status enforcement (PENDING/BANNED, DB-fresh) | ✓ | access-token.service.ts, unit |
| Setup | status + transactional first-admin bootstrap with token/throttle | ✓ | setup.service.ts, unit + live-Postgres e2e |
| Admin | user list/filter, role/status changes, last-admin guard | ✓ | admin.service.ts, unit+e2e |
| Admin | temp-password reset, emergency 2FA removal | ✓ | admin.service.ts, unit |
| Admin | MOD permission grant/list/revoke (global + per-server) | ✓ | admin.service.ts, unit |
| Servers | create+start, list, get (visibility-filtered) | ✓ | servers.service.ts, unit+e2e |
| Servers | start/stop/restart with CAS + advisory lock | ✓ | unit+e2e |
| Servers | graceful stop (RCON warn → save-all → docker stop) | ✓ | docker.service.ts RCON exec, e2e (mocked) |
| Servers | resource admission (disk statfs, memory ratio) | ✓ | unit |
| Servers | startup reconciliation | ✓ | unit |
| Servers | delete: container+row, data retained (202 sync) | ✓ | servers.service.ts:465-494 |
| Access | request/approve/revoke, OPEN/REQUEST/PRIVATE, non-disclosure | ✓ | server-access.service.ts, unit |
| Docker | socket-only client, degraded mode, guardrail container spec | ✓ | unit |
| Docker | host info, statfs disk, free mem | ✓ | unit |
| Gateway | WS auth (cookie/event), ADMIN metrics room, system.stats 10s |
✓ | events.gateway.ts, e2e |
| Common | CSRF origin guard, exact-origin CORS, helmet, ValidationPipe | ✓ | unit |
| Errors | PG-code filter (23505/23503/42P01/42703) | ✓ | unit |
| Deploy | compose (nestjs/postgres/caddy/prefetch), preflight, boot migrations | ✓ | image+smoke jobs |
| CI | lint/build/unit/migration/e2e/image/publish, Trivy, multi-arch | ✓ | configured CI gates |
| API | protocol-1 capability discovery with no-store | ✓ | app.controller.ts, unit |
Not implemented at all (old SPEC claimed or implied them as current): @nestjs/schedule cron (any), nestjs-pino logging, /users controller, /versions, PATCH /servers/:id (config), PATCH /servers/:id/version, server/panel icons, /panel/logo, /system/stats REST, magic links, OAuth endpoints, Minecraft linking endpoints, API keys, webhooks, audit log, system events, backups, scheduled tasks, notifications, plugins, file manager, player management, proxies, Bedrock, Ban table, pagination beyond GET /servers.
Generated from current implementation gaps. Priorities: P0 release blockers, P1 important before stable v1, P2 later improvements, P3 hygiene/acceptance.
- B-P0-2 Hosted-frontend auth compatibility: D-1 is decided and CHIPS primary is shipped. The PKCE authorization-code fallback remains reserved/not implemented and is still a release blocker for complete hosted-browser compatibility. Same-origin deployment is unaffected.
- B-P1-1 Atomic refresh rotation (§8.3): tx +
FOR UPDATE+jtiO(1) lookup + per-class 401 codes + replay semantics + frontend single-flight contract. - B-P1-2 Refresh/logout error contract: missing/malformed/expired refresh and missing-cookie logout MUST return 401, never 500 (
db-exception.filter.tsfall-through). - B-P1-3 Session hygiene:
GET /sessionsfiltersexpiresAt > now(); indexes onrefresh_tokens(user_id)and(expires_at); lazy + daily expired-row cleanup. - B-P1-4 WebSocket ticket (§8.6): single-use 60s ticket endpoint; keep cookie fast path; fixes the HttpOnly-token contradiction and Origin-less mobile handshakes.
- B-P1-5 Refresh TTL sync: consume
JWT_REFRESH_EXPIRES_IN(JWT exp, DBexpiresAt, cookiemaxAge); remove hardcoded7d. - B-P1-6 Scheduler dependency decision: add
@nestjs/schedule(in-process, single-instance) for cleanup sweeps when the first cron feature lands; there is no scheduler today. - B-P1-7 Identity normalization (§8.8): fix username case mismatch (normalize at registration or case-insensitive lookup); decide canonical identity policy (D-10).
- B-P1-8 Stable error envelope + request-id (§12.2): global
AppExceptionFilter, machine codes,X-Request-Id. - B-P1-9 Dead envs: wire or delete
LOGIN_THROTTLE_LIMIT/LOGIN_THROTTLE_TTL_MS; declare dead config a release blocker. - B-P1-10 MC container CPU/pids limits:
NanoCpus+PidsLimitinHostConfig. - B-P1-11 RCON credential management: per-server random
RCON_PASSWORDat create, AES-GCM-encrypted in the existingrcon_passwordcolumn, passed as env; unpins the current itzg-generated-password behavior. - B-P1-12 Per-account login brute-force counter: per-username in-memory counter (≥5 fails → 15 min lock, distinct 429 code), admin unlock; complements IP throttling.
- B-P1-13 Real Docker lifecycle e2e (release-only, §14.4): container create → run → graceful stop → delete → data-retention assertions against a real daemon before tagging.
- B-P1-15 Remaining concurrency and delete-contract coverage: setup initialization and setup throttling are covered by live e2e; refresh rotation concurrency and retained-data deletion semantics remain.
- B-P2-1 Version/icon/panel-logo endpoints and
/versions(Phase 3/4, §17) once the write architecture (D-8) lands. - B-P2-2 Session metadata columns (
userAgent,lastUsedAt); touch on rotation. - B-P2-3 WebSocket per-user socket cap + per-IP handshake rate (authenticated sockets are currently uncapped).
- B-P2-4 Restrict inter-container traffic on the
mcnetwork; per-server networks (proposed). - B-P2-5 Non-root backend user (
group_adddocker group instead ofuser: root). - B-P2-6 Backend
cap_drop: [ALL]+read_only: true+tmpfs: /tmp;__Host-cookie prefix + explicitpath; version-string consistency (1.0.0 vs 1.0 vs N/A). - B-P2-7 Pin itzg image (digest/tag) in compose; record resolved digest per server (proposed column).
- B-P2-8 Fix
system.statsfree-RAM semantics (container cgroup vs host); or document. - B-P2-9 Password hashing upgrade (D-9): HMAC-SHA384 pepper pre-hash (or Argon2id later) + UTF-8 byte measurement, no silent truncation (§18.1).
- B-P2-10 Declarative env validation (Joi/Zod) instead of manual preflight.
- B-P3-1 JWT
algorithms: ['HS256']explicit pin. - B-P3-2 Register enumeration oracle (409 + fast-fail) — accept + document.
- B-P3-3 Gate Swagger behind
SWAGGER_ENABLED(default off in prod). - B-P3-4 Document MOD global-grant vs visibility 404 nuance.
- B-P3-5 Accept in-memory 2FA lockout loss on restart (single-instance).
- B-P3-6 Log only PG code + requestId (not raw constraint messages).
- B-P3-7
decrypt()input validation → distinct error code. - B-P3-8
/setup/statuswrite-on-read upsert — accept or lazy-init. - B-P3-9 Dead double-throw in
grantModPermission; staleusernameclaim note. - B-P3-10 License selection (D-11) and README "open-source" wording.
All subsections are [PROPOSED] unless marked. Dependencies are explicit; nothing here exists in code today. Current endpoint tables are §7; these proposals stay separate.
Schema changes (accepted requirements): users.passwordHash MUST become nullable; add googleId (unique), githubId (unique), minecraftVerified; password login MUST reject null-hash accounts with a distinct code.
Google OAuth — security requirements (MUST):
- Local JWKS validation only (
https://www.googleapis.com/oauth2/v3/certs, RS256, cache bykidwith rotation;google-auth-libraryverifyIdTokenorjose).tokeninfois a debugging endpoint — forbidden in production (latency, throttling, availability coupling). - Mandatory claims:
iss ∈ {accounts.google.com, https://accounts.google.com},aud∈ configured allowlist,exp/iatfreshness,email_verified === truebefore any email-based logic; storesubasgoogleId(stable; never key on email).
GitHub OAuth — security requirements (MUST):
GET /userfor the numericid→githubId(never key onlogin, which is renameable), plusGET /user/emailswith theuser:emailscope selectingprimary && verified; reject when no verified primary email exists.
Token binding (DECISION D-5): one OAuth client_id shared by all backends means aud cannot distinguish backends — any token obtained by the frontend is replayable to any MinePanel backend (confused deputy). Recommended: POST /auth/oauth/challenge issues a single-use 5-min random challenge stored hashed; the frontend carries it through the provider flow (Google nonce, GitHub state) and presents it with the token; the backend consumes it atomically. PKCE MUST be used on the frontend leg regardless.
Identity linking (MUST): silent email-match linking is forbidden (account-takeover risk from recycled/attacker-controlled provider emails). Provider login matching an existing password account returns a LinkConfirmationRequired state; linking completes only after password re-authentication (or an existing session). Auto-link only when the account was created by the same provider id.
GitHub token exposure (accepted risk, mitigations MUST): a raw gho_… token (read-only scopes) reaches an arbitrary self-hosted backend; the spec requires verify-then-discard (never persist), redaction in all logs, TLS end-to-end (already enforced), and documentation of residual scope.
Username collisions: sanitize provider display names to ^[a-zA-Z0-9_]{3,32}$; on unique violation retry with a random 4-digit suffix (≤3 attempts) then 409 UsernameUnavailable.
Magic links (SMTP optional, Phase 1.5): unchanged product shape from the original design (one-time 15-min tokens, no user-enumeration, 501 when SMTP unconfigured) — [PROPOSED], requires the MagicLinkToken table.
Audit log (append-only, interceptor-based, AuditLog table), API keys (mpk_ prefix, hashed at rest, full owner permissions), outbound webhooks (HMAC-SHA256 signatures, retry 1×/5s, non-blocking), system events (retention 10k rows), historical metrics (MetricSnapshot 60s cadence, 30d retention). All gated ADMIN where specified in the original design. Depends on: error envelope (B-P1-8), scheduler (B-P1-6).
WebSocket real-time (3a): server.status/log/stats events, subscribe/unsubscribe, console.command — all require the WS ticket (B-P1-4) and per-user/console rate limits. Currently only system.stats exists (§7.7).
Backups (3c) — accepted contract (§18.4): consistency via save-off → save-all flush → copy → save-on (MUST re-enable in finally); staged+fsync+atomic-rename archives with SHA-256 manifest; verify-before-stop restore with rollback dir and atomic swap; disk preflight; archive safety (§18.3); exclusion rules; retention default 5; create/download = ADMIN or MOD SERVER_LIFECYCLE, restore/delete = ADMIN. Depends on write architecture (D-8), scheduler (B-P1-6), audit (Phase 2).
File manager (3h) — accepted path-safety algorithm (§18.2): containment by path.relative (never string startsWith), realpath re-check, no-follow (O_NOFOLLOW), fd-based ops, archive pre-validation, staged uploads (50 MB), read cap 5 MB, protected-files list (ops/whitelist/bans/level.dat) enforced on resolved paths, DELETE admin-only.
Plugins (3f/3g): Modrinth/Hangar sources, ServerPlugin table, install/update/toggle; PLUGIN_MANAGEMENT permission. Player management (3i): whitelist/bans/ops/kick via RCON or JSON files; UUID resolution (Mojang API cached 24h / offline UUIDv3); Ban table with auto-expiry cron. Scheduled tasks (3d) and notifications (3e): ScheduledTask/Notification tables; Discord webhook per-server; fire-and-forget with 1×/5s retry. Admin permissions dashboard (3j): exists as API already (§7.4); UI only.
Preset-driven creation (Survival SMP, Creative, Vanilla Hardcore, Modded…), advanced mode, mod picker (Modrinth preferred, CurseForge optional), ServerMod table, template clone (POST /servers/:id/clone). Depends on: write architecture (D-8), /versions metadata (B-P2-1), plugin/backup subsystems.
Velocity proxy (ServerProxy table, velocity.toml auto-generation, modern-forwarding secret encrypted, paper-global.yml patch on add/remove), proxy WebSocket events; Bedrock via GeyserMC plugin (minimal) or standalone itzg/minecraft-bedrock-server (BEDROCK provider, UDP 19132, no RCON). Note: the old SPEC's container-name convention minepanel-mc-{id} differs from the implemented mc-{id} — the proxy design MUST use the implemented convention.
KMP/Compose app + web player portal: server status cards, access requests, player profile, push notifications, quick lifecycle (mod), admin resource overview. Depends on: WS ticket + real-time events (B-P1-4/§17.3), historical metrics (Phase 2), notifications (Phase 3).
- Reality:
bcryptcost 10; DTO caps at 128 JS characters; bcrypt silently truncates input beyond 72 UTF-8 bytes (two passwords sharing a 72-byte prefix collide). - MUST: measure passwords in UTF-8 bytes (
Buffer.byteLength), never JS.length; never feed >72-byte passwords to bcrypt; no silent truncation, ever. - Recommended (pre-1.0, zero migration):
storedHash = bcrypt(base64url(HMAC-SHA384(pepper, utf8(password))), rounds)withPASSWORD_PEPPER(32 B hex, preflight-required, distinct fromJWT_SECRET/ENCRYPTION_KEY) and centralizedBCRYPT_ROUNDS(default 12, min 10). OWASP-endorsed construction; defeats hash-shucking; removes the length problem entirely. Fallback if pepper rejected: reject >64-byte passwords with 400PasswordTooLong. Argon2id is the[PROPOSED]long-term alternative. References: §20. - D-9 decides: pepper pre-hash vs byte-limit vs Argon2id migration.
18.2 File-manager path safety — normative algorithm (accepted, applies to any data-tree file op incl. deletion)
root = await fs.realpath(serverDir)once per request.- Reject NUL;
candidate = path.resolve(root, userPath)(never string-concat). - Containment:
rel = path.relative(root, candidate); reject iffrel === '..', starts with..+ sep, orpath.isAbsolute(rel). (StringstartsWith(root)is wrong:/data/srv1-evilpassesstartsWith('/data/srv1').) - Symlinks: for existing targets re-realpath and re-check containment; for creation, realpath the parent +
O_NOFOLLOWon the final component; default no-follow; internal symlinks read-only allowed. - TOCTOU: operate on opened fds; residual race bounded by privilege separation (§10.4).
- Archives: enumerate before writing; reject absolute paths,
.., device/fifo entries; resolve-or-reject link entries; per-file + total decompressed + count caps (zip-bomb); stream, never buffer. - Uploads: staged under
.minepanel-tmp/<uuid>on the same fs with a counting stream (never trustContent-Length), atomicrename; disk preflight before extraction. - Authorization re-checked per op (ADMIN or MOD
FILE_MANAGERscoped/global; DELETE admin-only); protected-files enforced on resolved paths.
SHA-256 integrity verification against the manifest before restore; pre-validation per §18.2.6; max compressed and expanded sizes (SHOULD defaults 10 GB / 30 GB with env overrides); insufficient-disk → 422 InsufficientResources before starting; rollback directory retained until the server reaches RUNNING; audit entries (Phase 2) on every create/restore/delete.
Running server: save-off → save-all flush → snapshot copy → save-on (MUST re-enable in finally; failure to re-enable = ERROR state). Stopped server: trivially consistent. Staging + fsync + atomic rename; manifest (serverId, version, createdAt, sha256, uncompressed bytes). Exclusions (normative): backups/, .minepanel-tmp/, session.lock, cache/.
| # | Decision | Status | Options / record | Blocking |
|---|---|---|---|---|
| D-1 | Hosted-frontend cross-origin auth (§8.5) | ADOPTED | CHIPS Partitioned cookies primary; PKCE authorization-code flow with memory-only bearer access token is the reserved fallback and is not implemented |
Complete hosted-browser compatibility |
| D-2 | Setup token mandatory? (§8.1) | ADOPTED | X-Setup-Token required; configured SETUP_TOKEN or one-time generated/logged bootstrap token |
Stable v1 |
| D-3 | Deletion semantics (§11.6) | ADOPTED | v1 retains host data; guarded manual cleanup; tombstone/backup/sweeper deferred | Stable v1 documentation |
| D-4 | Shipped socket default (§10.2) | OPEN | rootful vs rootless default | Compose config |
| D-5 | OAuth token binding (§17.1) | OPEN | challenge binding vs per-backend client vs assertion broker | Phase 1.5 |
| D-6 | WS auth primary path (§8.6) | OPEN | cookie vs ticket primary after D-1 / when cookies unavailable | Phase 3 real-time |
| D-7 | Identity linking policy (§17.1) | OPEN | explicit confirmation vs silent email-match | Phase 1.5 |
| D-8 | Write architecture (§10.4) | OPEN | sidecar vs rw mount with path module vs per-op exec | Phase 3 write features |
| D-9 | Password hashing (§18.1) | OPEN | pepper pre-hash vs byte-limit vs Argon2id | Stable v1 hardening |
| D-10 | Identity normalization (§8.8) | OPEN | normalize username at registration vs case-insensitive lookup | Stable v1 |
| D-11 | License (B-P3-10) | OPEN | e.g. MIT / Apache-2.0 / AGPL-3.0 | Any public release |
Browser/cookie behavior:
- MDN — Third-party cookies /
Partitioned(CHIPS): https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Third-party_cookies/Partitioned_cookies - Privacy Sandbox — next steps (Chrome user-choice): https://privacysandbox.google.com/blog/privacy-sandbox-next-steps
- Privacy Sandbox — CHIPS: https://privacysandbox.google.com/cookies/chips
- WebKit — Full Third-Party Cookie Blocking (ITP): https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/
- WebKit — Features in Safari 18.4 (CHIPS): https://webkit.org/blog/16574/webkit-features-in-safari-18-4/
- MDN — Firefox 131 release notes (CHIPS enabled): https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Releases/131
OAuth / identity:
- Google — Authenticate with a backend server (tokeninfo is debugging; local validation for production): https://developers.google.com/identity/sign-in/web/backend-auth
- Google — OpenID Connect API reference (
iss/aud/subrequirements): https://developers.google.com/identity/openid-connect/reference - Google — Verify the ID token on the server side: https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
- GitHub — REST API emails (
user:emailscope, primary/verified): https://docs.github.com/en/rest/users/emails - GitHub — Authorizing OAuth apps (scopes): https://docs.github.com/en/apps/oauth-apps/using-oauth-apps/authorizing-oauth-apps
Password storage:
- OWASP Password Storage Cheat Sheet (72-byte bcrypt limit; HMAC-SHA384 pepper pre-hash; Argon2id): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
The previous SPEC.md (pre-rewrite) was an ambitious design document that conflated three things. This revision separates them (§1 legend). Notable corrections:
- Never existed (claimed as current):
nestjs-pinologging (actual:ConsoleLogger),@nestjs/schedulecron (absent),pendingDeleteAt+ deletion cleanup,discordWebhook,googleId/githubId/minecraftVerified,Ban/MagicLinkToken/ApiKey/Webhook/SystemEvent/AuditLog/Backup/ScheduledTask/Notification/ServerPlugin/ServerMod/ServerProxytables,/usersendpoints,/versions, version-update and icon endpoints,/panel/logo,/system/statsREST, magic links, OAuth and Minecraft-linking endpoints, strict/standard/relaxed rate-limit tiers, per-username brute-force counter,refresh_tokens.userAgent/lastUsedAt, 24h sliding refresh renewal, rootless-Docker default,${XDG_RUNTIME_DIR}compose default, SvelteKit frontend. - Implemented differently than described: refresh rotates on every use (not within 24h of expiry); production
sameSiteisnone(docs claimedlax); PKs arerandomUUID(not cuid); ban deletes sessions (spec claimed it does not); 422 error code isInsufficientResources(not per-resource codes);DbExceptionFilteremits{message}only (spec claimed NestJS default shape); delete returns 202 for a synchronous op; RCON isdocker exec rcon-cli, not TCP RCON; themc-{id}container name (notminepanel-mc-{id}). - Unsafe examples removed:
startsWith(serverDir)path check (§18.2), "128 chars is safe with bcrypt" (§18.1),SameSite=None; Securedescribed as sufficient (§8.5),tokeninfo+ "optional audience check" OAuth flow (§17.1), silent email-match linking (§17.1), "production-ready"/"100% complete" claims (§2). - Product truth: "open-source" claim removed (UNLICENSED, private, no LICENSE); frontend stack corrected to React 19 + Vite 7 + Tailwind 4; version strings reconciled as inconsistent (B-P2-6).
This specification was validated against the repository working tree by: full SPEC read, git state inspection, independent read-only code scouts (implementation truth; schema/migrations/config; docs/claims/tests), first-hand verification of schema, bootstrap, auth, Docker, servers, gateway, controllers, guards, DTOs, compose, Dockerfile, and CI workflow. No commit hash is recorded here.