Add the operator-management page over *admin.Service (no new business logic).
- GET/POST /_gm/operators list + create operator
- POST /_gm/operators/{user}/disable|enable toggle access
- POST /_gm/operators/{user}/reset-password set a new password
Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so
the page renders in tests without a database. Create POST is mounted on the
collection path; per-row disable/enable/reset are guarded by the CSRF middleware
and redirect back. Passwords are never logged.
Tests: list render, create (+ username/password assertions), username-taken
conflict, disable/enable, reset (+ password assertion), missing-password 400,
bad-CSRF 403, and unavailable 503.
Docs: backend/docs/admin-console.md page inventory extended.
7.6 KiB
Operator console (/_gm)
The operator console is a server-rendered web UI for the platform's admin
operations. It is the human-facing counterpart to the JSON admin API under
/api/v1/admin/*: both call the same service layer, but the console renders
HTML pages an operator drives in a browser, while the JSON API stays internal
to the deployment for programmatic and test use.
Design choices
- Server-rendered, no client framework. Pages are rendered with the
standard library's
html/template. Navigation is by ordinary links and query parameters; every state change is an HTML formPOSTanswered with a Post/Redirect/Get redirect. There is no build step, no JavaScript framework, and no separate asset pipeline — a single embedded stylesheet under/_gm/assets/. - Reuses the existing admin auth. The console mounts behind the same
basicauth.Middleware(admin.Service)verifier that gates/api/v1/admin/*, so there is one credential store (admin_accounts, bcrypt-12) and no second secret to manage. - Lives in the backend. The backend owns the admin domain and the data, so rendering there lets the console call the service layer directly. The gateway stays a thin proxy.
Request path
Browser ── /_gm/* ──► edge Caddy ──► gateway (public listener)
gateway: anti-abuse `admin` class (per-IP rate limit, body + method limits)
└─► reverse proxy ──► backend /_gm/*
backend: basicauth.Middleware(admin.Service)
└─► CSRF guard (state-changing methods)
└─► console handler ──► admin service layer ──► html/template
The gateway preserves the inbound Host and relays the backend's 401 Basic
Auth challenge unchanged, so the browser shows its native credential dialog.
The gateway adds only the edge anti-abuse layer; authentication and every state
change are enforced by the backend. The gateway answers 502 when the backend
is unreachable. See the gateway README "Operator Console Proxy" section for the
admin route-class env vars.
Components (package internal/adminconsole)
The package is framework-agnostic (no gin) so it unit-tests in isolation:
Renderer— parses the embedded layout plus one content page per route and renders a named page wrapped in the shared layout. Rendering goes through an intermediate buffer, so a template failure never emits a partial document.CSRF— issues and verifies the stateless anti-CSRF token: HMAC-SHA256 over the authenticated username, keyed byBACKEND_ADMIN_CONSOLE_CSRF_KEY. When the key is unset a per-process random key is used (secure, but forms reset on restart and do not validate across replicas — set a shared key for multi-replica deployments).Assets— the embedded stylesheet filesystem served under/_gm/assets/.
The gin glue (route group, Basic Auth, the CSRF guard middleware, the per-page
handlers) lives in internal/server/handlers_admin_console.go and
internal/server/router.go (registerAdminConsoleRoutes).
CSRF protection
Because the console is sessionless (HTTP Basic Auth, whose credentials the browser replays automatically), state-changing requests are double-guarded:
- A stateless per-operator token (
_csrfform field) that a cross-site page cannot read or forge. - A same-origin
Origin/Referercheck (when the browser sends one), which relies on the gateway preserving the inboundHost.
Safe methods (GET/HEAD/OPTIONS) pass without a token.
Monitoring
The dashboard is the console landing page. It surfaces backend-visible
operational state — service health, game-runtime status, and queue depths —
read through the existing service and persistence layers. Richer cross-service
metrics are out of scope for the console itself: the /metrics Prometheus
exporters on backend and gateway are wired and enabled in the dev
deployment so a future Prometheus + Grafana stack can scrape them without code
changes.
Pages
| Path | Method | Purpose |
|---|---|---|
/_gm, /_gm/ |
GET | Dashboard: health, runtime/mail/notification status, queues. |
/_gm/assets/* |
GET | Embedded stylesheet. |
/_gm/users |
GET | Paginated account list. |
/_gm/users/{id} |
GET | Account detail: profile, entitlement, active sanctions. |
/_gm/users/{id}/block |
POST | Apply a permanent block (reason required). |
/_gm/users/{id}/entitlement |
POST | Set the entitlement tier. |
/_gm/users/{id}/soft-delete |
POST | Soft-delete the account (cascades). |
/_gm/games |
GET/POST | Paginated game list; POST creates a public game. |
/_gm/games/{id} |
GET | Game detail with the runtime snapshot. |
/_gm/games/{id}/force-start |
POST | Force-start the game. |
/_gm/games/{id}/force-stop |
POST | Force-stop the game. |
/_gm/games/{id}/ban-member |
POST | Ban a member (user id + reason). |
/_gm/games/{id}/runtime/restart |
POST | Restart the engine container. |
/_gm/games/{id}/runtime/patch |
POST | Patch the runtime to a target version. |
/_gm/games/{id}/runtime/force-next-turn |
POST | Force the next turn now. |
/_gm/engine-versions |
GET/POST | Version registry; POST registers a version. |
/_gm/engine-versions/{ver}/disable |
POST | Disable a registered version. |
/_gm/operators |
GET/POST | Admin-account list; POST creates an operator. |
/_gm/operators/{user}/disable |
POST | Disable an operator. |
/_gm/operators/{user}/enable |
POST | Re-enable an operator. |
/_gm/operators/{user}/reset-password |
POST | Reset an operator's password. |
Each page reuses the same service layer as the corresponding /api/v1/admin/*
JSON endpoint; the console adds no business logic. Collection-mutating POSTs are
mounted on the collection path (POST /_gm/games, POST /_gm/engine-versions)
so a static action segment never collides with a path parameter in the gin
router. Unblocking a user is not yet available because the JSON admin API
exposes no remove-sanction endpoint.
Configuration
| Variable | Where | Notes |
|---|---|---|
BACKEND_ADMIN_CONSOLE_CSRF_KEY |
backend | CSRF token key; unset → per-process random key. |
BACKEND_ADMIN_BOOTSTRAP_USER |
backend | Bootstrap operator account (shared with the JSON admin API). |
BACKEND_ADMIN_BOOTSTRAP_PASSWORD |
backend | Bootstrap operator password. |
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_* |
gateway | admin route-class rate-limit and body budgets. |