Admin Panel

Admin Panel

@colyseus/admin is a browser-based operations console for projects using @colyseus/database. The panel provides user management (browse, ban, revoke sessions), CRUD over your drizzle tables, a live room inspector, and a customizable dashboard. Everything is gated by session-based login with role-based access control and an append-only audit log.

For a lightweight, no-auth dashboard intended for room and process inspection, see the Monitoring Panel instead. The admin panel is the heavier counterpart, built for production operations.

⚠️

@colyseus/admin is in beta: APIs may change between releases as we incorporate community feedback. Share yours on the public roadmap.

Installation

@colyseus/admin has two peer dependencies: @colyseus/database (user store, audit log, drizzle table introspection) and @colyseus/auth (JWT signing, password hashing). Install all three together:

npm install --save @colyseus/admin @colyseus/database @colyseus/auth

Mounting

The admin panel mounts in two ways:

Spread admin({}) into createRouter. The factory returns both the SPA static-serving routes and the REST API in one map:

src/app.config.ts
import { defineServer, createRouter } from "colyseus";
import { GameDatabase } from "@colyseus/database";
import { admin } from "@colyseus/admin";
 
export const db = new GameDatabase({ connectionString: process.env.DATABASE_URL });
 
export default defineServer({
    database: db,
    routes: createRouter({
        ...admin({}),
    }),
});

The database option defaults to GameDatabase.current, set automatically when you construct new GameDatabase(...). Multi-database deployments should pass database explicitly (e.g. admin({ database: db })) to disambiguate which instance the panel drives.

Usage

Once mounted, start your server and navigate to http://localhost:2567/admin in your browser. If you customized uiPath, adjust the URL accordingly.

The REST API mounts in parallel at http://localhost:2567/admin-api and is consumed by the SPA. The endpoints are normal createEndpoint(...) routes and respect the same admin.guard() semantics if you wrap them for use elsewhere.

First-run bootstrap

The admin panel includes no built-in admin user. The first visit to /admin checks for an existing admin account and, if none exists, renders a one-time bootstrap form.

After bootstrap:

  • A session cookie is set (HttpOnly, SameSite=Strict, JWT-signed).
  • You’re redirected into the panel with full admin role access.
  • All sibling routes gated with admin.guard() (e.g. the Playground or Monitor) now resolve against the same session.

See Authentication & RBAC for the full session, role, and revocation story.

Hardening for production

The defaults are tuned for local development. Before exposing the panel to the public internet:

  • Set a strong JWT_SECRET and SESSION_SECRET: see Auth Module → Required Environment Secrets. Rotating JWT_SECRET invalidates all admin sessions (SESSION_SECRET only signs OAuth state cookies).

  • Wire auth.settings.onForgotPassword to deliver password reset emails through your transactional email provider, the same hook that powers your player-side reset flow.

  • Tighten the session cookie via the session option: set an explicit domain, an appropriate maxAge, and confirm the Secure/SameSite flags match your deployment.

  • Disable allowDevHeader if you don’t need it. It auto-disables when NODE_ENV === "production", but setting it to false explicitly avoids surprises in mixed environments.

  • Swap the in-memory rate limiters for a Redis-backed implementation on multi-process deployments. The default token buckets don’t share state across instances.

  • Gate the Monitor and Playground behind admin.guard() so you don’t have multiple unauthenticated panels exposed:

    src/app.config.ts
    routes: createRouter({
        ...playground({ use: [admin.guard()] }),
        ...monitor({    use: [admin.guard()] }),
        ...admin({}),
    }),

Options reference

admin(options) accepts the following AdminOptions:

OptionTypeDefaultDescription
databaseGameDatabaseGameDatabase.currentThe database powering the user store, audit log, and table introspection. Pass explicitly for multi-database setups.
tablesRecord<string, Table>database.tablesMap of drizzle tables exposed in the panel. Spread database.tables and append custom tables: { ...database.tables, guilds }.
resourcesRecord<string, ResourceDefinition>{}Per-table UI/UX overrides: labels, visible columns, form fields, actions, policies. See Resources & CRUD.
uiPathstring"/admin"Mount path for the admin SPA.
apiPathstring"/admin-api"Mount path for the admin REST API.
uiDistDirstringbuilt-inAbsolute path to the built UI assets. Defaults to the bundled build/ directory inside the package.
sessionSessionConfigsee belowCookie config: ttlSeconds, cookieDomain, cookieSameSite, cookieSecure.
resolveUserId(ctx) => Promise<string | undefined>session cookie, falling back to the X-User-Id header in devOverride the identity resolver to integrate with another auth scheme.
enforceRbacbooleantrueSet to false to skip RBAC entirely (development only).
allowDevHeaderbooleantrue in dev, false in prodPermit X-User-Id: <id> as a fallback identity for curl/puppeteer testing.
minPasswordLengthnumber8Minimum password length accepted by the bootstrap and reset endpoints. Plain length check, no complexity rules.
onResetRequest(ctx) => void | Promise<void>logs to stdoutPanel-specific override for the password-reset email. Most projects should configure auth.settings.onForgotPassword instead and reuse their player-side delivery. Receives { email, userId, token, url }.
rateLimit{ login, bootstrap, requestReset }in-memory token bucketsPer-endpoint rate limiters. Pass false per slot to disable, or a custom RateLimiter to swap in a Redis-backed implementation.
dashboard{ presets, widgets }all presets enabledConfigure the dashboard homepage. See Dashboard & widgets.
loggerLogger | nullJSON stdoutPino-compatible logger. Pass null to silence the panel’s internal logs.

Session defaults

The session config controls the JWT cookie. Defaults:

session: {
    ttlSeconds: 7 * 24 * 60 * 60, // 1 week
    cookieSameSite: "Strict",
    cookieSecure: process.env.NODE_ENV === "production",
    // cookieDomain: undefined  ← host-only; set explicitly when serving from a subdomain
}

The cookie is always HttpOnly and signed with JWT_SECRET. Revoking a session is done via db.auth.bumpTokenVersion(userId). See Database → Authentication.

Next steps