diff --git a/backend/README.md b/backend/README.md
index 05ec325..69e7254 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -27,10 +27,16 @@ The implementation specification lives in `PLAN.md`.
| ------------------ | ----------------------------------------------- | ------------------------------------- |
| `/api/v1/public/*` | none | Registration, code confirmation |
| `/api/v1/user/*` | `X-User-ID` injected by gateway | Authenticated end users |
-| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators |
+| `/api/v1/admin/*` | HTTP Basic Auth against `admin_accounts` | Platform administrators (JSON) |
+| `/_gm`, `/_gm/*` | HTTP Basic Auth against `admin_accounts` | Operator console (server-rendered HTML)|
| `/healthz` | none | Liveness probe |
| `/readyz` | none | Readiness probe |
+The `/_gm` operator console is the human-facing surface for the admin
+operations; it reuses the admin Basic Auth verifier, renders with
+`html/template`, and is the only admin surface exposed publicly (through
+the gateway). See `docs/admin-console.md`.
+
The full contract is documented in `openapi.yaml` and validated at
runtime by the contract tests under `internal/server/`.
@@ -100,6 +106,7 @@ fast.
| `BACKEND_GAME_STATE_ROOT` | yes | — | Host directory bind-mounted into engine containers. |
| `BACKEND_ADMIN_BOOTSTRAP_USER` | no | — | Initial admin username; idempotent insert. |
| `BACKEND_ADMIN_BOOTSTRAP_PASSWORD` | no | — | Initial admin password; required if user is set. |
+| `BACKEND_ADMIN_CONSOLE_CSRF_KEY` | no | random per-process | Secret keying the `/_gm` console CSRF token. Set a shared value across replicas; unset uses a per-process random key (forms reset on restart). |
| `BACKEND_GEOIP_DB_PATH` | yes | — | Filesystem path to GeoLite2 Country `.mmdb`. |
| `BACKEND_OTEL_TRACES_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`. |
| `BACKEND_OTEL_METRICS_EXPORTER` | no | `otlp` | `none`, `otlp`, `stdout`, `prometheus`. |
diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go
index 966f1c9..8d87527 100644
--- a/backend/cmd/backend/main.go
+++ b/backend/cmd/backend/main.go
@@ -22,6 +22,7 @@ import (
_ "time/tzdata"
"galaxy/backend/internal/admin"
+ "galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/app"
"galaxy/backend/internal/auth"
"galaxy/backend/internal/config"
@@ -37,6 +38,7 @@ import (
"galaxy/backend/internal/mail"
"galaxy/backend/internal/metricsapi"
"galaxy/backend/internal/notification"
+ "galaxy/backend/internal/opsstatus"
backendpostgres "galaxy/backend/internal/postgres"
"galaxy/backend/push"
"galaxy/backend/internal/runtime"
@@ -360,6 +362,32 @@ func run(ctx context.Context) (err error) {
return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready()
}
+ var consoleCSRF *adminconsole.CSRF
+ if cfg.AdminConsole.CSRFKey != "" {
+ consoleCSRF = adminconsole.NewCSRF([]byte(cfg.AdminConsole.CSRFKey))
+ } else {
+ consoleCSRF, err = adminconsole.NewRandomCSRF()
+ if err != nil {
+ return fmt.Errorf("init admin console CSRF: %w", err)
+ }
+ logger.Warn("admin console CSRF key not set; using a per-process random key (forms reset on restart, not valid across replicas)",
+ zap.String("env", "BACKEND_ADMIN_CONSOLE_CSRF_KEY"))
+ }
+ adminConsoleHandlers := backendserver.NewAdminConsoleHandlers(backendserver.AdminConsoleDeps{
+ CSRF: consoleCSRF,
+ Monitor: opsstatus.NewStore(db),
+ Ready: ready,
+ Users: userSvc,
+ Games: lobbySvc,
+ Runtime: runtimeSvc,
+ EngineVersions: engineVersionSvc,
+ Operators: adminSvc,
+ Mail: mailSvc,
+ Notifications: notifSvc,
+ Diplomail: diplomailSvc,
+ Logger: logger,
+ })
+
handler, err := backendserver.NewRouter(backendserver.RouterDependencies{
Logger: logger,
Telemetry: telemetryRT,
@@ -388,6 +416,7 @@ func run(ctx context.Context) (err error) {
AdminGeo: adminGeoHandlers,
UserGames: userGamesHandlers,
UserMail: userMailHandlers,
+ AdminConsole: adminConsoleHandlers,
})
if err != nil {
return fmt.Errorf("build backend router: %w", err)
diff --git a/backend/docs/admin-console.md b/backend/docs/admin-console.md
new file mode 100644
index 0000000..74e908d
--- /dev/null
+++ b/backend/docs/admin-console.md
@@ -0,0 +1,128 @@
+# 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 form `POST` answered 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 by `BACKEND_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:
+
+1. A stateless per-operator token (`_csrf` form field) that a cross-site page
+ cannot read or forge.
+2. A same-origin `Origin`/`Referer` check (when the browser sends one), which
+ relies on the gateway preserving the inbound `Host`.
+
+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. |
+| `/_gm/mail` | GET | Mail deliveries (paginated) + a dead-letter snapshot. |
+| `/_gm/mail/deliveries/{id}` | GET | Delivery detail with its attempts. |
+| `/_gm/mail/deliveries/{id}/resend`| POST | Re-enqueue a non-sent delivery. |
+| `/_gm/notifications` | GET | Notifications, dead-letters, and malformed intents overview. |
+| `/_gm/broadcast` | GET/POST | Admin multi-game diplomatic broadcast. |
+
+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. |
diff --git a/backend/internal/adminconsole/assets/console.css b/backend/internal/adminconsole/assets/console.css
new file mode 100644
index 0000000..6b276b7
--- /dev/null
+++ b/backend/internal/adminconsole/assets/console.css
@@ -0,0 +1,103 @@
+/* Admin console stylesheet. Deliberately small and dependency-free: the
+ console is an internal operator tool, not a public surface. */
+:root {
+ --bg: #11151c;
+ --panel: #1b2230;
+ --panel-hi: #232c3d;
+ --ink: #e6ebf2;
+ --ink-dim: #9aa7ba;
+ --line: #2c3850;
+ --accent: #5aa9ff;
+ --danger: #ff6b6b;
+ --ok: #4ecb8d;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font: 15px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+}
+a { color: var(--accent); text-decoration: none; }
+a:hover { text-decoration: underline; }
+.topbar {
+ display: flex;
+ align-items: center;
+ gap: 1.5rem;
+ padding: 0.6rem 1.2rem;
+ background: var(--panel);
+ border-bottom: 1px solid var(--line);
+}
+.topbar .brand { font-weight: 700; letter-spacing: 0.04em; }
+.topbar .mainnav { display: flex; gap: 1rem; flex: 1; }
+.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
+.topbar .who { color: var(--ink-dim); }
+.content { padding: 1.5rem; max-width: 1100px; margin: 0 auto; }
+h1 { font-size: 1.4rem; margin: 0 0 0.4rem; }
+.lede { color: var(--ink-dim); margin-top: 0; }
+.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
+.card {
+ display: block;
+ padding: 1rem 1.2rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ color: var(--ink);
+}
+.card:hover { background: var(--panel-hi); text-decoration: none; }
+.card h2 { font-size: 1.05rem; margin: 0 0 0.3rem; color: var(--accent); }
+.card p { margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
+
+.panel {
+ padding: 0.9rem 1.1rem;
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ margin-bottom: 1rem;
+}
+.panel h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--ink); }
+.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
+.grid .panel { margin-bottom: 0; }
+.kv { list-style: none; margin: 0; padding: 0; }
+.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
+.counts { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
+.counts td { padding: 0.2rem 0; border-bottom: 1px solid var(--line); color: var(--ink-dim); }
+.counts td.num { text-align: right; color: var(--ink); font-variant-numeric: tabular-nums; }
+.bignum { font-size: 1.6rem; margin: 0; color: var(--ink); }
+.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
+.errors { border-color: var(--danger); }
+.errors ul { margin: 0; padding-left: 1.1rem; color: var(--danger); }
+.ok { color: var(--ok); }
+.bad { color: var(--danger); }
+
+.list { width: 100%; border-collapse: collapse; font-size: 0.9rem; margin-bottom: 1rem; }
+.list th, .list td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line); }
+.list th { color: var(--ink-dim); font-weight: 600; }
+.list tr:hover td { background: var(--panel-hi); }
+.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
+.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.8rem; }
+.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
+.form input, .form select {
+ background: var(--bg);
+ color: var(--ink);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 0.35rem 0.5rem;
+ font: inherit;
+}
+button {
+ background: var(--accent);
+ color: #06121f;
+ border: 0;
+ border-radius: 6px;
+ padding: 0.4rem 0.9rem;
+ font: inherit;
+ font-weight: 600;
+ cursor: pointer;
+}
+button:hover { filter: brightness(1.1); }
+button.danger { background: var(--danger); color: #1a0606; }
+code { background: var(--bg); padding: 0.05rem 0.3rem; border-radius: 4px; }
+.actions { display: flex; flex-wrap: wrap; gap: 0.6rem; margin: 0.8rem 0; }
+.actions form { margin: 0; }
+.subnav { color: var(--ink-dim); margin: -0.3rem 0 1rem; font-size: 0.9rem; }
diff --git a/backend/internal/adminconsole/csrf.go b/backend/internal/adminconsole/csrf.go
new file mode 100644
index 0000000..a0317a1
--- /dev/null
+++ b/backend/internal/adminconsole/csrf.go
@@ -0,0 +1,54 @@
+package adminconsole
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "fmt"
+)
+
+// CSRF issues and verifies the stateless anti-CSRF token used by the admin
+// console. The token is an HMAC-SHA256 over the authenticated operator's
+// username keyed by a process secret, so a cross-site request cannot forge it
+// without already being able to read an authenticated page. The console is
+// sessionless (HTTP Basic Auth), which makes a stateless, per-operator token
+// the natural fit.
+type CSRF struct {
+ key []byte
+}
+
+// NewCSRF returns a CSRF signer keyed by key. A shared key across backend
+// replicas lets a form rendered by one replica validate on another; callers
+// that pass a per-process random key (see NewRandomCSRF) accept that forms do
+// not survive a restart or span replicas.
+func NewCSRF(key []byte) *CSRF {
+ return &CSRF{key: key}
+}
+
+// NewRandomCSRF returns a CSRF signer keyed by a fresh 32-byte random secret.
+// It is the secure default when no shared key is configured.
+func NewRandomCSRF() (*CSRF, error) {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return nil, fmt.Errorf("generate admin console CSRF key: %w", err)
+ }
+ return &CSRF{key: key}, nil
+}
+
+// Token returns the anti-CSRF token bound to username.
+func (c *CSRF) Token(username string) string {
+ mac := hmac.New(sha256.New, c.key)
+ mac.Write([]byte(username))
+ return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
+}
+
+// Verify reports whether token is the valid anti-CSRF token for username. The
+// comparison runs in constant time relative to the token bytes.
+func (c *CSRF) Verify(username, token string) bool {
+ if token == "" {
+ return false
+ }
+ expected := c.Token(username)
+ return hmac.Equal([]byte(token), []byte(expected))
+}
diff --git a/backend/internal/adminconsole/csrf_test.go b/backend/internal/adminconsole/csrf_test.go
new file mode 100644
index 0000000..5e13f3a
--- /dev/null
+++ b/backend/internal/adminconsole/csrf_test.go
@@ -0,0 +1,42 @@
+package adminconsole
+
+import "testing"
+
+func TestCSRFTokenRoundTrip(t *testing.T) {
+ signer := NewCSRF([]byte("shared-secret"))
+ token := signer.Token("alice")
+
+ if !signer.Verify("alice", token) {
+ t.Fatal("valid token rejected")
+ }
+ if signer.Verify("bob", token) {
+ t.Fatal("token accepted for a different operator")
+ }
+ if signer.Verify("alice", "") {
+ t.Fatal("empty token accepted")
+ }
+ if signer.Verify("alice", token+"x") {
+ t.Fatal("tampered token accepted")
+ }
+}
+
+func TestCSRFKeySeparation(t *testing.T) {
+ a := NewCSRF([]byte("key-a"))
+ b := NewCSRF([]byte("key-b"))
+ if a.Token("operator") == b.Token("operator") {
+ t.Fatal("tokens collide across distinct keys")
+ }
+ if b.Verify("operator", a.Token("operator")) {
+ t.Fatal("token minted under one key verified under another")
+ }
+}
+
+func TestRandomCSRFRoundTrip(t *testing.T) {
+ signer, err := NewRandomCSRF()
+ if err != nil {
+ t.Fatalf("NewRandomCSRF: %v", err)
+ }
+ if !signer.Verify("operator", signer.Token("operator")) {
+ t.Fatal("random-key token failed to round-trip")
+ }
+}
diff --git a/backend/internal/adminconsole/dashboard.go b/backend/internal/adminconsole/dashboard.go
new file mode 100644
index 0000000..ed39ea3
--- /dev/null
+++ b/backend/internal/adminconsole/dashboard.go
@@ -0,0 +1,24 @@
+package adminconsole
+
+// StatusCount pairs a status label with its current row count for the
+// dashboard's per-status tables. It is the view-layer counterpart of the
+// data gathered by the ops-status reader; the server handler maps between
+// them so this package stays free of database concerns.
+type StatusCount struct {
+ Status string
+ Count int64
+}
+
+// DashboardData is the view model for the console landing page. MonitorAvailable
+// is false when no ops-status reader is wired, in which case the monitoring
+// panels are omitted. Errors carries non-fatal probe failures for display.
+type DashboardData struct {
+ MonitorAvailable bool
+ BackendReady bool
+ PostgresHealthy bool
+ Runtimes []StatusCount
+ MailDeliveries []StatusCount
+ NotificationRoutes []StatusCount
+ NotificationMalformed int64
+ Errors []string
+}
diff --git a/backend/internal/adminconsole/doc.go b/backend/internal/adminconsole/doc.go
new file mode 100644
index 0000000..dbf6092
--- /dev/null
+++ b/backend/internal/adminconsole/doc.go
@@ -0,0 +1,18 @@
+// Package adminconsole renders the server-side operator console mounted by the
+// backend under the `/_gm` route group.
+//
+// The console is a multi-page, server-rendered surface built on the standard
+// library's html/template package: navigation is driven by request path and
+// query, state changes are submitted with HTML forms and answered with a
+// Post/Redirect/Get redirect. The package owns three concerns and nothing
+// transport-specific:
+//
+// - Renderer composes the shared layout with one content page per route.
+// - CSRF issues and verifies the stateless anti-CSRF token embedded in every
+// state-changing form.
+// - Assets exposes the embedded stylesheet served under `/_gm/assets/`.
+//
+// The gin glue (route registration, Basic Auth, the CSRF guard middleware, and
+// the per-page handlers) lives in package server; this package stays free of
+// the web framework so it can be unit-tested in isolation.
+package adminconsole
diff --git a/backend/internal/adminconsole/games.go b/backend/internal/adminconsole/games.go
new file mode 100644
index 0000000..eb733ff
--- /dev/null
+++ b/backend/internal/adminconsole/games.go
@@ -0,0 +1,67 @@
+package adminconsole
+
+// GameRow is one line in the games list table.
+type GameRow struct {
+ GameID string
+ GameName string
+ Visibility string
+ Status string
+ Owner string
+ Players string
+ TurnSchedule string
+ CreatedAt string
+}
+
+// GamesListData is the view model for the paginated games list.
+type GamesListData struct {
+ Items []GameRow
+ Page int
+ PageSize int
+ Total int
+ HasPrev bool
+ HasNext bool
+ PrevPage int
+ NextPage int
+}
+
+// GameDetailData is the view model for a single game, combining the lobby
+// record with the runtime snapshot and the available actions.
+type GameDetailData struct {
+ GameID string
+ GameName string
+ Description string
+ Visibility string
+ Status string
+ Owner string
+ MinPlayers int32
+ MaxPlayers int32
+ StartGapHours int32
+ StartGapPlayers int32
+ TurnSchedule string
+ TargetEngineVersion string
+ EnrollmentEndsAt string
+ CreatedAt string
+ StartedAt string
+ FinishedAt string
+
+ HasRuntime bool
+ RuntimeStatus string
+ CurrentEngineVersion string
+ EngineHealth string
+ CurrentTurn int32
+ NextGenerationAt string
+ Paused bool
+}
+
+// EngineVersionRow is one line in the engine-version registry table.
+type EngineVersionRow struct {
+ Version string
+ ImageRef string
+ Enabled bool
+ CreatedAt string
+}
+
+// EngineVersionsData is the view model for the engine-version registry page.
+type EngineVersionsData struct {
+ Items []EngineVersionRow
+}
diff --git a/backend/internal/adminconsole/mail.go b/backend/internal/adminconsole/mail.go
new file mode 100644
index 0000000..f6983d7
--- /dev/null
+++ b/backend/internal/adminconsole/mail.go
@@ -0,0 +1,86 @@
+package adminconsole
+
+// MailDeliveryRow is one line in the mail deliveries table.
+type MailDeliveryRow struct {
+ DeliveryID string
+ Template string
+ Status string
+ Attempts int32
+ NextAttempt string
+ Created string
+}
+
+// MailDeadLetterRow is one line in the mail dead-letters table.
+type MailDeadLetterRow struct {
+ DeliveryID string
+ Reason string
+ Archived string
+}
+
+// MailData is the view model for the mail page: a paginated deliveries list
+// plus a snapshot of dead-letters.
+type MailData struct {
+ Deliveries []MailDeliveryRow
+ DeadLetters []MailDeadLetterRow
+ Page int
+ PageSize int
+ Total int64
+ HasPrev bool
+ HasNext bool
+ PrevPage int
+ NextPage int
+}
+
+// MailAttemptRow is one delivery attempt on the mail detail page.
+type MailAttemptRow struct {
+ AttemptNo int32
+ Outcome string
+ Started string
+ Finished string
+ Error string
+}
+
+// MailDeliveryDetail is the view model for a single delivery.
+type MailDeliveryDetail struct {
+ DeliveryID string
+ Template string
+ Status string
+ Attempts int32
+ NextAttempt string
+ LastError string
+ Created string
+ Sent string
+ DeadLettered string
+ CanResend bool
+ AttemptRows []MailAttemptRow
+}
+
+// NotificationRow is one line in the notifications table.
+type NotificationRow struct {
+ NotificationID string
+ Kind string
+ UserID string
+ Created string
+}
+
+// NotificationDeadLetterRow is one line in the notification dead-letters table.
+type NotificationDeadLetterRow struct {
+ NotificationID string
+ RouteID string
+ Reason string
+ Archived string
+}
+
+// MalformedRow is one line in the malformed-intents table.
+type MalformedRow struct {
+ ID string
+ Reason string
+ Received string
+}
+
+// NotificationsData is the view model for the notifications overview page.
+type NotificationsData struct {
+ Notifications []NotificationRow
+ DeadLetters []NotificationDeadLetterRow
+ Malformed []MalformedRow
+}
diff --git a/backend/internal/adminconsole/message.go b/backend/internal/adminconsole/message.go
new file mode 100644
index 0000000..2da0461
--- /dev/null
+++ b/backend/internal/adminconsole/message.go
@@ -0,0 +1,11 @@
+package adminconsole
+
+// MessageData is the view model for the generic message page used to render
+// not-found, validation, and operation-failure notices. Class selects the CSS
+// styling (for example "bad" for errors); BackHref, when set, renders a link
+// back to a relevant page.
+type MessageData struct {
+ Message string
+ Class string
+ BackHref string
+}
diff --git a/backend/internal/adminconsole/operators.go b/backend/internal/adminconsole/operators.go
new file mode 100644
index 0000000..e3545fe
--- /dev/null
+++ b/backend/internal/adminconsole/operators.go
@@ -0,0 +1,14 @@
+package adminconsole
+
+// OperatorRow is one line in the operators (admin accounts) table.
+type OperatorRow struct {
+ Username string
+ CreatedAt string
+ LastUsedAt string
+ Disabled bool
+}
+
+// OperatorsData is the view model for the operators page.
+type OperatorsData struct {
+ Items []OperatorRow
+}
diff --git a/backend/internal/adminconsole/render.go b/backend/internal/adminconsole/render.go
new file mode 100644
index 0000000..fac0a53
--- /dev/null
+++ b/backend/internal/adminconsole/render.go
@@ -0,0 +1,107 @@
+package adminconsole
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "html/template"
+ "io"
+ "io/fs"
+ "path"
+ "strings"
+)
+
+//go:embed templates
+var templatesFS embed.FS
+
+//go:embed assets
+var assetsFS embed.FS
+
+// Renderer holds the parsed admin console templates. It composes one template
+// set per content page, each combining the shared layout (defining the page
+// chrome and the "layout" entry template) with that page's "content" block, so
+// rendering a page is a single ExecuteTemplate call against the "layout" name.
+type Renderer struct {
+ pages map[string]*template.Template
+}
+
+// PageData is the view model passed to every admin console page. Title is the
+// document title; Username is the authenticated operator; CSRFToken is the
+// per-operator token embedded into state-changing forms; ActiveNav marks the
+// highlighted navigation entry; Data carries the page-specific payload.
+type PageData struct {
+ Title string
+ Username string
+ CSRFToken string
+ ActiveNav string
+ Data any
+}
+
+// NewRenderer parses the embedded layout and every content page under
+// templates/pages, returning a Renderer ready to serve them. It fails when a
+// template cannot be parsed.
+func NewRenderer() (*Renderer, error) {
+ base, err := template.New("layout").ParseFS(templatesFS, "templates/layout.gohtml")
+ if err != nil {
+ return nil, fmt.Errorf("parse admin console layout: %w", err)
+ }
+
+ pageFiles, err := fs.Glob(templatesFS, "templates/pages/*.gohtml")
+ if err != nil {
+ return nil, fmt.Errorf("enumerate admin console pages: %w", err)
+ }
+ if len(pageFiles) == 0 {
+ return nil, fmt.Errorf("admin console: no page templates found under templates/pages")
+ }
+
+ pages := make(map[string]*template.Template, len(pageFiles))
+ for _, file := range pageFiles {
+ name := strings.TrimSuffix(path.Base(file), ".gohtml")
+ clone, err := base.Clone()
+ if err != nil {
+ return nil, fmt.Errorf("clone admin console layout for %q: %w", name, err)
+ }
+ if _, err := clone.ParseFS(templatesFS, file); err != nil {
+ return nil, fmt.Errorf("parse admin console page %q: %w", name, err)
+ }
+ pages[name] = clone
+ }
+
+ return &Renderer{pages: pages}, nil
+}
+
+// MustNewRenderer is like NewRenderer but panics on error. The templates are
+// embedded at build time, so a parse failure is a programmer error rather than
+// a runtime condition.
+func MustNewRenderer() *Renderer {
+ renderer, err := NewRenderer()
+ if err != nil {
+ panic(err)
+ }
+ return renderer
+}
+
+// Render writes the named page, wrapped in the shared layout, to w using data.
+// It returns an error when page is unknown or template execution fails; the
+// page is rendered into an intermediate buffer first so a mid-render failure
+// never emits a partial document to w.
+func (r *Renderer) Render(w io.Writer, page string, data PageData) error {
+ tmpl, ok := r.pages[page]
+ if !ok {
+ return fmt.Errorf("admin console: unknown page %q", page)
+ }
+
+ var buf bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
+ return fmt.Errorf("render admin console page %q: %w", page, err)
+ }
+
+ _, err := buf.WriteTo(w)
+ return err
+}
+
+// Assets returns the embedded static asset tree rooted at the assets directory,
+// suitable for serving under `/_gm/assets/`.
+func Assets() (fs.FS, error) {
+ return fs.Sub(assetsFS, "assets")
+}
diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go
new file mode 100644
index 0000000..d0cde20
--- /dev/null
+++ b/backend/internal/adminconsole/render_test.go
@@ -0,0 +1,67 @@
+package adminconsole
+
+import (
+ "bytes"
+ "io/fs"
+ "strings"
+ "testing"
+)
+
+func TestRendererRendersDashboard(t *testing.T) {
+ renderer, err := NewRenderer()
+ if err != nil {
+ t.Fatalf("NewRenderer: %v", err)
+ }
+
+ var buf bytes.Buffer
+ err = renderer.Render(&buf, "dashboard", PageData{
+ Title: "Dashboard",
+ Username: "ops-bob",
+ ActiveNav: "dashboard",
+ })
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+
+ out := buf.String()
+ for _, want := range []string{
+ "",
+ "Dashboard",
+ "ops-bob",
+ `href="/_gm/users"`,
+ "/_gm/assets/console.css",
+ } {
+ if !strings.Contains(out, want) {
+ t.Errorf("rendered page missing %q\n--- page ---\n%s", want, out)
+ }
+ }
+}
+
+func TestRendererUnknownPage(t *testing.T) {
+ renderer := MustNewRenderer()
+ if err := renderer.Render(&bytes.Buffer{}, "does-not-exist", PageData{}); err == nil {
+ t.Fatal("expected an error rendering an unknown page")
+ }
+}
+
+func TestRendererEscapesUsername(t *testing.T) {
+ renderer := MustNewRenderer()
+
+ var buf bytes.Buffer
+ if err := renderer.Render(&buf, "dashboard", PageData{Username: ""}); err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ if strings.Contains(buf.String(), "") {
+ t.Error("username was not HTML-escaped in the rendered page")
+ }
+}
+
+func TestAssetsContainsStylesheet(t *testing.T) {
+ fsys, err := Assets()
+ if err != nil {
+ t.Fatalf("Assets: %v", err)
+ }
+ if _, err := fs.Stat(fsys, "console.css"); err != nil {
+ t.Fatalf("console.css missing from embedded assets: %v", err)
+ }
+}
diff --git a/backend/internal/adminconsole/templates/layout.gohtml b/backend/internal/adminconsole/templates/layout.gohtml
new file mode 100644
index 0000000..8634190
--- /dev/null
+++ b/backend/internal/adminconsole/templates/layout.gohtml
@@ -0,0 +1,28 @@
+{{define "layout" -}}
+
+
+
+
+
+
+{{.Title}} · Galaxy GM
+
+
+
+
+
+{{template "content" .}}
+
+
+
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml
new file mode 100644
index 0000000..317ac33
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml
@@ -0,0 +1,21 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+Broadcast
+
+
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/dashboard.gohtml b/backend/internal/adminconsole/templates/pages/dashboard.gohtml
new file mode 100644
index 0000000..721323f
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/dashboard.gohtml
@@ -0,0 +1,69 @@
+{{define "content" -}}
+Dashboard
+Signed in as {{.Username}}.
+{{with .Data}}
+
+ Health
+
+ - Backend ready: {{if .BackendReady}}yes{{else}}no{{end}}
+ - Postgres: {{if .PostgresHealthy}}healthy{{else}}unreachable{{end}}
+
+
+{{if .MonitorAvailable}}
+
+
+ Game runtimes
+ {{template "statuscounts" .Runtimes}}
+
+
+ Mail deliveries
+ {{template "statuscounts" .MailDeliveries}}
+
+
+ Notification routes
+ {{template "statuscounts" .NotificationRoutes}}
+
+
+ Malformed notifications
+ {{.NotificationMalformed}}
+
+
+{{if .Errors}}
+
+ Collection errors
+ {{range .Errors}}- {{.}}
{{end}}
+
+{{end}}
+{{else}}
+Monitoring is not wired in this deployment.
+{{end}}
+{{end}}
+
+{{- end}}
+
+{{define "statuscounts" -}}
+{{if .}}
+
+{{range .}}| {{.Status}} | {{.Count}} |
{{end}}
+
+{{else}}
+none
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/engine_versions.gohtml b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml
new file mode 100644
index 0000000..c68e7c3
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/engine_versions.gohtml
@@ -0,0 +1,30 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+Engine versions
+{{with .Data}}
+
+| Version | Image | Enabled | Created | |
+
+{{range .Items}}
+
+| {{.Version}} |
+{{.ImageRef}} |
+{{if .Enabled}}yes{{else}}no{{end}} |
+{{.CreatedAt}} |
+{{if .Enabled}}{{end}} |
+
+{{else}}| no engine versions |
{{end}}
+
+
+{{end}}
+
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/game_detail.gohtml b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
new file mode 100644
index 0000000..990a96b
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/game_detail.gohtml
@@ -0,0 +1,65 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+{{with .Data}}
+« all games
+{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}
+
+
+Game
+
+- Game ID:
{{.GameID}}
+- Visibility: {{.Visibility}}
+- Status: {{.Status}}
+- Owner: {{.Owner}}
+- Players: {{.MinPlayers}}–{{.MaxPlayers}}
+- Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players
+- Turn schedule: {{.TurnSchedule}}
+- Target engine: {{.TargetEngineVersion}}
+- Enrollment ends: {{.EnrollmentEndsAt}}
+- Created: {{.CreatedAt}}
+- Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}
+- Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}
+
+{{if .Description}}{{.Description}}
{{end}}
+
+
+
+
+
+
+
+Runtime
+{{if .HasRuntime}}
+
+- Status: {{.RuntimeStatus}}
+- Engine version: {{.CurrentEngineVersion}}
+- Engine health: {{.EngineHealth}}
+- Current turn: {{.CurrentTurn}}
+- Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}
+- Paused: {{if .Paused}}yes{{else}}no{{end}}
+
+
+
+
+
+
+{{else}}
+No runtime record for this game yet.
+{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/games.gohtml b/backend/internal/adminconsole/templates/pages/games.gohtml
new file mode 100644
index 0000000..d213cdb
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/games.gohtml
@@ -0,0 +1,43 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+Games
+{{with .Data}}
+
+
+{{end}}
+
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/mail.gohtml b/backend/internal/adminconsole/templates/pages/mail.gohtml
new file mode 100644
index 0000000..ea4a93e
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/mail.gohtml
@@ -0,0 +1,32 @@
+{{define "content" -}}
+Mail
+
+{{with .Data}}
+
+Deliveries
+
+| Delivery | Template | Status | Attempts | Next attempt | Created |
+
+{{range .Deliveries}}
+{{.DeliveryID}} | {{.Template}} | {{.Status}} | {{.Attempts}} | {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}} | {{.Created}} |
+{{else}}| no deliveries |
{{end}}
+
+
+
+
+
+Dead-letters
+
+| Delivery | Reason | Archived |
+
+{{range .DeadLetters}}{{.DeliveryID}} | {{.Reason}} | {{.Archived}} |
+{{else}}| no dead-letters |
{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml
new file mode 100644
index 0000000..d587259
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/mail_delivery.gohtml
@@ -0,0 +1,33 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+{{with .Data}}
+« mail
+Delivery
+
+
+- Delivery ID:
{{.DeliveryID}}
+- Template: {{.Template}}
+- Status: {{.Status}}
+- Attempts: {{.Attempts}}
+- Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}
+- Created: {{.Created}}
+- Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}
+- Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}
+- Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}
+
+{{if .CanResend}}
+
+{{else}}Already sent — resend is not available.
{{end}}
+
+
+Attempts
+
+| # | Outcome | Started | Finished | Error |
+
+{{range .AttemptRows}}| {{.AttemptNo}} | {{.Outcome}} | {{.Started}} | {{if .Finished}}{{.Finished}}{{else}}—{{end}} | {{.Error}} |
+{{else}}| no attempts |
{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/message.gohtml b/backend/internal/adminconsole/templates/pages/message.gohtml
new file mode 100644
index 0000000..8f30abc
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/message.gohtml
@@ -0,0 +1,7 @@
+{{define "content" -}}
+{{.Title}}
+{{with .Data}}
+{{.Message}}
+{{if .BackHref}}« back
{{end}}
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/notifications.gohtml b/backend/internal/adminconsole/templates/pages/notifications.gohtml
new file mode 100644
index 0000000..eb1d27d
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/notifications.gohtml
@@ -0,0 +1,27 @@
+{{define "content" -}}
+Notifications
+
+{{with .Data}}
+
+Recent notifications
+| ID | Kind | User | Created |
+{{range .Notifications}}{{.NotificationID}} | {{.Kind}} | {{.UserID}} | {{.Created}} |
+{{else}}| none |
{{end}}
+
+
+
+Dead-letters
+| Notification | Route | Reason | Archived |
+{{range .DeadLetters}}{{.NotificationID}} | {{.RouteID}} | {{.Reason}} | {{.Archived}} |
+{{else}}| none |
{{end}}
+
+
+
+Malformed intents
+| ID | Reason | Received |
+{{range .Malformed}}{{.ID}} | {{.Reason}} | {{.Received}} |
+{{else}}| none |
{{end}}
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/operators.gohtml b/backend/internal/adminconsole/templates/pages/operators.gohtml
new file mode 100644
index 0000000..f944ace
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/operators.gohtml
@@ -0,0 +1,38 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+Operators
+{{with .Data}}
+
+| Username | Status | Created | Last used | Actions |
+
+{{range .Items}}
+
+| {{.Username}} |
+{{if .Disabled}}disabled{{else}}active{{end}} |
+{{.CreatedAt}} |
+{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}} |
+
+
+{{if .Disabled}}
+
+{{else}}
+
+{{end}}
+
+
+ |
+
+{{else}}| no operators |
{{end}}
+
+
+{{end}}
+
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
new file mode 100644
index 0000000..b01d4fb
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml
@@ -0,0 +1,68 @@
+{{define "content" -}}
+{{$csrf := .CSRFToken}}
+{{with .Data}}
+« all users
+{{.Email}}
+{{if .Deleted}}This account is soft-deleted.
{{end}}
+
+
+Account
+
+- User ID:
{{.UserID}}
+- User name: {{.UserName}}
+- Display name: {{.DisplayName}}
+- Preferred language: {{.PreferredLanguage}}
+- Time zone: {{.TimeZone}}
+- Declared country: {{.DeclaredCountry}}
+- Status: {{if .Blocked}}blocked{{else}}active{{end}}
+- Created: {{.CreatedAt}}
+- Updated: {{.UpdatedAt}}
+
+
+
+
+Entitlement
+
+- Tier: {{.Tier}} ({{if .IsPaid}}paid{{else}}free{{end}})
+- Source: {{.EntitlementSource}}
+- Reason: {{.EntitlementReason}}
+- Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}
+
+
+
+
+
+Active sanctions
+{{if .Sanctions}}
+
+{{range .Sanctions}}| {{.SanctionCode}} | {{.Scope}} | {{.ReasonCode}} | {{.AppliedAt}} |
{{end}}
+
+{{else}}none
{{end}}
+{{if .Blocked}}
+User is permanently blocked. Unblock is not available in the current admin API.
+{{else}}
+
+{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/templates/pages/users.gohtml b/backend/internal/adminconsole/templates/pages/users.gohtml
new file mode 100644
index 0000000..0cc371c
--- /dev/null
+++ b/backend/internal/adminconsole/templates/pages/users.gohtml
@@ -0,0 +1,27 @@
+{{define "content" -}}
+Users
+{{with .Data}}
+
+| Email | User name | Display | Tier | Status | Created |
+
+{{range .Items}}
+
+| {{.Email}} |
+{{.UserName}} |
+{{.DisplayName}} |
+{{.Tier}} |
+{{if .Deleted}}deleted{{else if .Blocked}}blocked{{else}}active{{end}} |
+{{.CreatedAt}} |
+
+{{else}}
+| no users |
+{{end}}
+
+
+
+{{end}}
+{{- end}}
diff --git a/backend/internal/adminconsole/users.go b/backend/internal/adminconsole/users.go
new file mode 100644
index 0000000..dc2b210
--- /dev/null
+++ b/backend/internal/adminconsole/users.go
@@ -0,0 +1,61 @@
+package adminconsole
+
+// UserRow is one line in the users list table.
+type UserRow struct {
+ UserID string
+ Email string
+ UserName string
+ DisplayName string
+ Tier string
+ Blocked bool
+ Deleted bool
+ CreatedAt string
+}
+
+// UsersListData is the view model for the paginated users list.
+type UsersListData struct {
+ Items []UserRow
+ Page int
+ PageSize int
+ Total int
+ HasPrev bool
+ HasNext bool
+ PrevPage int
+ NextPage int
+}
+
+// SanctionView is one active sanction shown on the user detail page.
+type SanctionView struct {
+ SanctionCode string
+ Scope string
+ ReasonCode string
+ AppliedAt string
+ ExpiresAt string
+}
+
+// UserDetailData is the view model for a single user's detail page,
+// combining the account aggregate with the form option lists.
+type UserDetailData struct {
+ UserID string
+ Email string
+ UserName string
+ DisplayName string
+ PreferredLanguage string
+ TimeZone string
+ DeclaredCountry string
+ Blocked bool
+ Deleted bool
+ CreatedAt string
+ UpdatedAt string
+
+ Tier string
+ IsPaid bool
+ EntitlementSource string
+ EntitlementReason string
+ EntitlementEnds string
+
+ Sanctions []SanctionView
+
+ // Tiers lists the selectable entitlement tiers for the form.
+ Tiers []string
+}
diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go
index ee6cae9..baf6c7b 100644
--- a/backend/internal/config/config.go
+++ b/backend/internal/config/config.go
@@ -55,6 +55,8 @@ const (
envAdminBootstrapUser = "BACKEND_ADMIN_BOOTSTRAP_USER"
envAdminBootstrapPassword = "BACKEND_ADMIN_BOOTSTRAP_PASSWORD"
+ envAdminConsoleCSRFKey = "BACKEND_ADMIN_CONSOLE_CSRF_KEY"
+
envGeoIPDBPath = "BACKEND_GEOIP_DB_PATH"
envOTelTracesExporter = "BACKEND_OTEL_TRACES_EXPORTER"
@@ -208,6 +210,7 @@ type Config struct {
Docker DockerConfig
Game GameConfig
Admin AdminBootstrapConfig
+ AdminConsole AdminConsoleConfig
GeoIP GeoIPConfig
Telemetry TelemetryConfig
Auth AuthConfig
@@ -308,6 +311,15 @@ type AdminBootstrapConfig struct {
Password string
}
+// AdminConsoleConfig configures the server-rendered operator console.
+// CSRFKey is the secret keying the console's stateless anti-CSRF token.
+// When empty the console falls back to a per-process random key, which is
+// secure but means forms do not survive a restart and do not validate across
+// replicas; set a shared key when running more than one backend instance.
+type AdminConsoleConfig struct {
+ CSRFKey string
+}
+
// GeoIPConfig configures the GeoLite2 country database used by geo lookups.
type GeoIPConfig struct {
DBPath string
@@ -644,6 +656,8 @@ func LoadFromEnv() (Config, error) {
cfg.Admin.User = loadString(envAdminBootstrapUser, cfg.Admin.User)
cfg.Admin.Password = loadString(envAdminBootstrapPassword, cfg.Admin.Password)
+ cfg.AdminConsole.CSRFKey = loadString(envAdminConsoleCSRFKey, cfg.AdminConsole.CSRFKey)
+
cfg.GeoIP.DBPath = loadString(envGeoIPDBPath, cfg.GeoIP.DBPath)
cfg.Telemetry.TracesExporter = strings.ToLower(loadString(envOTelTracesExporter, cfg.Telemetry.TracesExporter))
diff --git a/backend/internal/opsstatus/opsstatus.go b/backend/internal/opsstatus/opsstatus.go
new file mode 100644
index 0000000..03ed330
--- /dev/null
+++ b/backend/internal/opsstatus/opsstatus.go
@@ -0,0 +1,139 @@
+// Package opsstatus reads point-in-time operational signals from Postgres for
+// the admin console dashboard: database reachability, per-status counts of game
+// runtimes, mail deliveries, and notification routes, plus the malformed
+// notification-intent count.
+//
+// It is a read-only projection built entirely through the go-jet query builder
+// against the generated table bindings; it owns no business logic and mutates
+// nothing. Richer, historical metrics are out of scope — those belong to the
+// Prometheus exporters wired on `backend` and `gateway`.
+package opsstatus
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ "galaxy/backend/internal/postgres/jet/backend/table"
+
+ "github.com/go-jet/jet/v2/postgres"
+)
+
+// defaultCollectTimeout bounds a single Collect call so a slow or wedged
+// database cannot hang the dashboard request.
+const defaultCollectTimeout = 3 * time.Second
+
+// StatusCount pairs a status value with the number of rows currently in it.
+type StatusCount struct {
+ Status string
+ Count int64
+}
+
+// Snapshot is a point-in-time view of the operational signals rendered on the
+// dashboard. Errors collects per-query failures so a single failing probe
+// degrades to a visible note rather than failing the whole page.
+type Snapshot struct {
+ PostgresHealthy bool
+ Runtimes []StatusCount
+ MailDeliveries []StatusCount
+ NotificationRoutes []StatusCount
+ NotificationMalformed int64
+ Errors []string
+}
+
+// Reader collects an operational Snapshot. The admin console depends on this
+// interface so the dashboard can be tested without a database.
+type Reader interface {
+ Collect(ctx context.Context) Snapshot
+}
+
+// Store is the Postgres-backed Reader.
+type Store struct {
+ db *sql.DB
+ timeout time.Duration
+}
+
+// NewStore constructs a Store reading from db.
+func NewStore(db *sql.DB) *Store {
+ return &Store{db: db, timeout: defaultCollectTimeout}
+}
+
+// Collect gathers the dashboard signals within a bounded timeout. It never
+// returns an error: a failed probe is recorded in Snapshot.Errors and the
+// remaining probes still run, except that a failed Postgres ping short-circuits
+// the rest (the dependent queries would only fail the same way).
+func (s *Store) Collect(ctx context.Context) Snapshot {
+ ctx, cancel := context.WithTimeout(ctx, s.timeout)
+ defer cancel()
+
+ var snap Snapshot
+
+ if err := s.db.PingContext(ctx); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("postgres ping: %v", err))
+ return snap
+ }
+ snap.PostgresHealthy = true
+
+ if counts, err := s.statusCounts(ctx, table.RuntimeRecords.Status, table.RuntimeRecords); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("runtime status counts: %v", err))
+ } else {
+ snap.Runtimes = counts
+ }
+
+ if counts, err := s.statusCounts(ctx, table.MailDeliveries.Status, table.MailDeliveries); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("mail delivery counts: %v", err))
+ } else {
+ snap.MailDeliveries = counts
+ }
+
+ if counts, err := s.statusCounts(ctx, table.NotificationRoutes.Status, table.NotificationRoutes); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("notification route counts: %v", err))
+ } else {
+ snap.NotificationRoutes = counts
+ }
+
+ if n, err := s.countAll(ctx, table.NotificationMalformedIntents); err != nil {
+ snap.Errors = append(snap.Errors, fmt.Sprintf("malformed notification count: %v", err))
+ } else {
+ snap.NotificationMalformed = n
+ }
+
+ return snap
+}
+
+// statusCounts runs `SELECT status, COUNT(*) FROM GROUP BY status`
+// through jet and returns the rows ordered by status.
+func (s *Store) statusCounts(ctx context.Context, status postgres.ColumnString, from postgres.ReadableTable) ([]StatusCount, error) {
+ stmt := postgres.SELECT(
+ status.AS("status_count.status"),
+ postgres.COUNT(postgres.STAR).AS("status_count.count"),
+ ).FROM(from).GROUP_BY(status).ORDER_BY(status.ASC())
+
+ var rows []struct {
+ Status string `alias:"status_count.status"`
+ Count int64 `alias:"status_count.count"`
+ }
+ if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
+ return nil, err
+ }
+
+ out := make([]StatusCount, len(rows))
+ for i, row := range rows {
+ out[i] = StatusCount{Status: row.Status, Count: row.Count}
+ }
+ return out, nil
+}
+
+// countAll runs `SELECT COUNT(*) FROM ` through jet.
+func (s *Store) countAll(ctx context.Context, from postgres.ReadableTable) (int64, error) {
+ stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(from)
+
+ var row struct {
+ Count int64 `alias:"count"`
+ }
+ if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
+ return 0, err
+ }
+ return row.Count, nil
+}
diff --git a/backend/internal/opsstatus/opsstatus_test.go b/backend/internal/opsstatus/opsstatus_test.go
new file mode 100644
index 0000000..b2edf27
--- /dev/null
+++ b/backend/internal/opsstatus/opsstatus_test.go
@@ -0,0 +1,155 @@
+package opsstatus_test
+
+import (
+ "context"
+ "database/sql"
+ "net/url"
+ "testing"
+ "time"
+
+ "galaxy/backend/internal/mail"
+ "galaxy/backend/internal/opsstatus"
+ backendpg "galaxy/backend/internal/postgres"
+ pgshared "galaxy/postgres"
+
+ "github.com/google/uuid"
+ testcontainers "github.com/testcontainers/testcontainers-go"
+ tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
+ "github.com/testcontainers/testcontainers-go/wait"
+)
+
+const (
+ pgImage = "postgres:16-alpine"
+ pgUser = "galaxy"
+ pgPassword = "galaxy"
+ pgDatabase = "galaxy_backend"
+ pgSchema = "backend"
+ pgStartup = 90 * time.Second
+ pgOpTO = 10 * time.Second
+)
+
+// startPostgres mirrors the per-package scaffolding used by the other store
+// tests: spin up Postgres, apply migrations, return *sql.DB.
+func startPostgres(t *testing.T) *sql.DB {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
+ t.Cleanup(cancel)
+
+ pgContainer, err := tcpostgres.Run(ctx, pgImage,
+ tcpostgres.WithDatabase(pgDatabase),
+ tcpostgres.WithUsername(pgUser),
+ tcpostgres.WithPassword(pgPassword),
+ testcontainers.WithWaitStrategy(
+ wait.ForLog("database system is ready to accept connections").
+ WithOccurrence(2).
+ WithStartupTimeout(pgStartup),
+ ),
+ )
+ if err != nil {
+ t.Skipf("postgres testcontainer unavailable, skipping: %v", err)
+ }
+ t.Cleanup(func() {
+ if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil {
+ t.Errorf("terminate postgres container: %v", termErr)
+ }
+ })
+
+ baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable")
+ if err != nil {
+ t.Fatalf("connection string: %v", err)
+ }
+ scopedDSN, err := dsnWithSearchPath(baseDSN, pgSchema)
+ if err != nil {
+ t.Fatalf("scope dsn: %v", err)
+ }
+
+ cfg := pgshared.DefaultConfig()
+ cfg.PrimaryDSN = scopedDSN
+ cfg.OperationTimeout = pgOpTO
+
+ db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...)
+ if err != nil {
+ t.Fatalf("open primary: %v", err)
+ }
+ t.Cleanup(func() { _ = db.Close() })
+
+ if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil {
+ t.Fatalf("ping: %v", err)
+ }
+ if err := backendpg.ApplyMigrations(ctx, db); err != nil {
+ t.Fatalf("apply migrations: %v", err)
+ }
+ return db
+}
+
+func dsnWithSearchPath(baseDSN, schema string) (string, error) {
+ parsed, err := url.Parse(baseDSN)
+ if err != nil {
+ return "", err
+ }
+ values := parsed.Query()
+ values.Set("search_path", schema)
+ if values.Get("sslmode") == "" {
+ values.Set("sslmode", "disable")
+ }
+ parsed.RawQuery = values.Encode()
+ return parsed.String(), nil
+}
+
+func TestStoreCollect(t *testing.T) {
+ db := startPostgres(t)
+ store := opsstatus.NewStore(db)
+ ctx := context.Background()
+
+ // Empty schema: queries must execute cleanly with zero counts.
+ empty := store.Collect(ctx)
+ if !empty.PostgresHealthy {
+ t.Fatal("PostgresHealthy must be true against a reachable database")
+ }
+ if len(empty.Errors) != 0 {
+ t.Fatalf("unexpected collection errors: %v", empty.Errors)
+ }
+ if got := totalCount(empty.MailDeliveries); got != 0 {
+ t.Fatalf("mail deliveries total = %d, want 0", got)
+ }
+ if len(empty.Runtimes) != 0 || len(empty.NotificationRoutes) != 0 {
+ t.Fatalf("expected empty status slices, got runtimes=%v routes=%v", empty.Runtimes, empty.NotificationRoutes)
+ }
+ if empty.NotificationMalformed != 0 {
+ t.Fatalf("malformed notifications = %d, want 0", empty.NotificationMalformed)
+ }
+
+ // Enqueue one mail delivery and confirm the GROUP BY count reflects it.
+ mailStore := mail.NewStore(db)
+ inserted, err := mailStore.InsertEnqueue(ctx, mail.EnqueueArgs{
+ DeliveryID: uuid.New(),
+ TemplateID: mail.TemplateLoginCode,
+ IdempotencyKey: uuid.NewString(),
+ Recipients: []string{"ops@example.test"},
+ ContentType: "text/plain",
+ Subject: "hello",
+ Body: []byte("hi"),
+ })
+ if err != nil {
+ t.Fatalf("insert mail delivery: %v", err)
+ }
+ if !inserted {
+ t.Fatal("expected the delivery to be inserted")
+ }
+
+ after := store.Collect(ctx)
+ if len(after.Errors) != 0 {
+ t.Fatalf("unexpected collection errors after insert: %v", after.Errors)
+ }
+ if got := totalCount(after.MailDeliveries); got != 1 {
+ t.Fatalf("mail deliveries total after insert = %d, want 1 (statuses: %v)", got, after.MailDeliveries)
+ }
+}
+
+func totalCount(counts []opsstatus.StatusCount) int64 {
+ var total int64
+ for _, c := range counts {
+ total += c.Count
+ }
+ return total
+}
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
new file mode 100644
index 0000000..a23badc
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console.go
@@ -0,0 +1,235 @@
+package server
+
+import (
+ "bytes"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/opsstatus"
+ "galaxy/backend/internal/server/httperr"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+// AdminConsoleHandlers renders the server-side operator console mounted under
+// the `/_gm` route group. It wraps the framework-agnostic
+// adminconsole.Renderer and CSRF signer with the gin glue: the per-page
+// handlers, the embedded static-asset handler, and the CSRF guard middleware
+// applied to state-changing requests. Authentication is provided by the shared
+// admin Basic Auth middleware mounted on the group, so this type assumes the
+// caller has already been verified.
+type AdminConsoleHandlers struct {
+ renderer *adminconsole.Renderer
+ csrf *adminconsole.CSRF
+ assets http.Handler
+ monitor opsstatus.Reader
+ ready func() bool
+ users UserAdmin
+ games GameAdmin
+ runtime RuntimeAdmin
+ engineVersions EngineVersionAdmin
+ operators OperatorAdmin
+ mail MailAdmin
+ notifications NotificationAdmin
+ diplomail DiplomailAdmin
+ logger *zap.Logger
+}
+
+// AdminConsoleDeps bundles the collaborators for the operator console. Every
+// field is optional: a nil Renderer or CSRF falls back to the embedded default
+// templates and a per-process random key; a nil Monitor renders the dashboard
+// without the monitoring panels; a nil Ready reports backend readiness as not
+// ready; a nil Logger falls back to zap.NewNop.
+type AdminConsoleDeps struct {
+ Renderer *adminconsole.Renderer
+ CSRF *adminconsole.CSRF
+ Monitor opsstatus.Reader
+ Ready func() bool
+ Users UserAdmin
+ Games GameAdmin
+ Runtime RuntimeAdmin
+ EngineVersions EngineVersionAdmin
+ Operators OperatorAdmin
+ Mail MailAdmin
+ Notifications NotificationAdmin
+ Diplomail DiplomailAdmin
+ Logger *zap.Logger
+}
+
+// NewAdminConsoleHandlers constructs the console handler set from deps. It
+// panics only on conditions that are unrecoverable at startup (template parse
+// failure or crypto/rand failure), both of which indicate a broken build or
+// host rather than a runtime input.
+func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
+ logger := deps.Logger
+ if logger == nil {
+ logger = zap.NewNop()
+ }
+ renderer := deps.Renderer
+ if renderer == nil {
+ renderer = adminconsole.MustNewRenderer()
+ }
+ csrf := deps.CSRF
+ if csrf == nil {
+ generated, err := adminconsole.NewRandomCSRF()
+ if err != nil {
+ panic(err)
+ }
+ csrf = generated
+ }
+
+ assetsFS, err := adminconsole.Assets()
+ if err != nil {
+ panic(err)
+ }
+
+ return &AdminConsoleHandlers{
+ renderer: renderer,
+ csrf: csrf,
+ assets: http.StripPrefix("/_gm/assets/", http.FileServer(http.FS(assetsFS))),
+ monitor: deps.Monitor,
+ ready: deps.Ready,
+ users: deps.Users,
+ games: deps.Games,
+ runtime: deps.Runtime,
+ engineVersions: deps.EngineVersions,
+ operators: deps.Operators,
+ mail: deps.Mail,
+ notifications: deps.Notifications,
+ diplomail: deps.Diplomail,
+ logger: logger.Named("http.admin.console"),
+ }
+}
+
+// Dashboard renders the console landing page (GET /_gm and GET /_gm/),
+// including the monitoring panels when an ops-status reader is wired.
+func (h *AdminConsoleHandlers) Dashboard() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ data := adminconsole.DashboardData{}
+ if h.ready != nil {
+ data.BackendReady = h.ready()
+ }
+ if h.monitor != nil {
+ data.MonitorAvailable = true
+ snapshot := h.monitor.Collect(c.Request.Context())
+ data.PostgresHealthy = snapshot.PostgresHealthy
+ data.Runtimes = toViewCounts(snapshot.Runtimes)
+ data.MailDeliveries = toViewCounts(snapshot.MailDeliveries)
+ data.NotificationRoutes = toViewCounts(snapshot.NotificationRoutes)
+ data.NotificationMalformed = snapshot.NotificationMalformed
+ data.Errors = snapshot.Errors
+ }
+ h.render(c, http.StatusOK, "dashboard", "dashboard", "Dashboard", data)
+ }
+}
+
+// toViewCounts maps ops-status counts to the console's view-layer counts.
+func toViewCounts(in []opsstatus.StatusCount) []adminconsole.StatusCount {
+ if len(in) == 0 {
+ return nil
+ }
+ out := make([]adminconsole.StatusCount, len(in))
+ for i, sc := range in {
+ out[i] = adminconsole.StatusCount{Status: sc.Status, Count: sc.Count}
+ }
+ return out
+}
+
+// Asset serves the embedded console static assets under `/_gm/assets/`.
+func (h *AdminConsoleHandlers) Asset() gin.HandlerFunc {
+ return gin.WrapH(h.assets)
+}
+
+// RequireCSRF returns middleware guarding state-changing requests against
+// cross-site request forgery. Safe methods pass through untouched. For unsafe
+// methods it requires both a same-origin Origin/Referer header (when the
+// browser sends one) and a valid per-operator token in the `_csrf` form field;
+// either check failing yields 403.
+func (h *AdminConsoleHandlers) RequireCSRF() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if isSafeHTTPMethod(c.Request.Method) {
+ c.Next()
+ return
+ }
+ if !sameOriginRequest(c.Request) {
+ httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "cross-origin request rejected")
+ return
+ }
+ username, _ := basicauth.UsernameFromContext(c.Request.Context())
+ if !h.csrf.Verify(username, c.PostForm("_csrf")) {
+ httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "invalid or missing CSRF token")
+ return
+ }
+ c.Next()
+ }
+}
+
+// render composes the data common to every console page (operator name, CSRF
+// token, active navigation entry) and writes the named page. It renders into an
+// intermediate buffer so a template failure surfaces as a clean 500 without
+// emitting a partial document.
+func (h *AdminConsoleHandlers) render(c *gin.Context, status int, page, activeNav, title string, data any) {
+ username, _ := basicauth.UsernameFromContext(c.Request.Context())
+
+ var buf bytes.Buffer
+ err := h.renderer.Render(&buf, page, adminconsole.PageData{
+ Title: title,
+ Username: username,
+ CSRFToken: h.csrf.Token(username),
+ ActiveNav: activeNav,
+ Data: data,
+ })
+ if err != nil {
+ h.logger.Error("render admin console page", zap.String("page", page), zap.Error(err))
+ httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "failed to render page")
+ return
+ }
+
+ c.Data(status, "text/html; charset=utf-8", buf.Bytes())
+}
+
+// renderMessage renders the generic message page (not-found, validation, or
+// operation-failure notices). class selects the CSS styling and backHref, when
+// non-empty, adds a back link.
+func (h *AdminConsoleHandlers) renderMessage(c *gin.Context, status int, activeNav, title, message, class, backHref string) {
+ h.render(c, status, "message", activeNav, title, adminconsole.MessageData{
+ Message: message,
+ Class: class,
+ BackHref: backHref,
+ })
+}
+
+// isSafeHTTPMethod reports whether method is a read-only HTTP method that the
+// CSRF guard may let through without a token.
+func isSafeHTTPMethod(method string) bool {
+ switch method {
+ case http.MethodGet, http.MethodHead, http.MethodOptions:
+ return true
+ default:
+ return false
+ }
+}
+
+// sameOriginRequest reports whether the request's Origin (or, failing that,
+// Referer) names the same host as the request itself. A request that carries
+// neither header is treated as same-origin, leaving the CSRF token as the sole
+// guard; a malformed or cross-host value is rejected. This relies on the
+// gateway reverse proxy preserving the inbound Host header.
+func sameOriginRequest(r *http.Request) bool {
+ source := r.Header.Get("Origin")
+ if source == "" {
+ source = r.Header.Get("Referer")
+ }
+ if source == "" {
+ return true
+ }
+ parsed, err := url.Parse(source)
+ if err != nil || parsed.Host == "" {
+ return false
+ }
+ return strings.EqualFold(parsed.Host, r.Host)
+}
diff --git a/backend/internal/server/handlers_admin_console_games.go b/backend/internal/server/handlers_admin_console_games.go
new file mode 100644
index 0000000..b1ac5b9
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_games.go
@@ -0,0 +1,423 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/lobby"
+ "galaxy/backend/internal/runtime"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+// GameAdmin is the subset of the lobby service the console uses for games.
+type GameAdmin interface {
+ ListAdminGames(ctx context.Context, page, pageSize int) (lobby.GamePage, error)
+ GetGame(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
+ CreateGame(ctx context.Context, input lobby.CreateGameInput) (lobby.GameRecord, error)
+ AdminForceStart(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
+ AdminForceStop(ctx context.Context, gameID uuid.UUID) (lobby.GameRecord, error)
+ AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, reason string) (lobby.Membership, error)
+}
+
+// RuntimeAdmin is the subset of the runtime service the console uses.
+type RuntimeAdmin interface {
+ GetRuntime(ctx context.Context, gameID uuid.UUID) (runtime.RuntimeRecord, error)
+ AdminRestart(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
+ AdminPatch(ctx context.Context, gameID uuid.UUID, targetVersion string) (runtime.OperationLog, error)
+ AdminForceNextTurn(ctx context.Context, gameID uuid.UUID) (runtime.OperationLog, error)
+}
+
+// EngineVersionAdmin is the subset of the engine-version service the console uses.
+type EngineVersionAdmin interface {
+ List(ctx context.Context) ([]runtime.EngineVersion, error)
+ Register(ctx context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error)
+ Disable(ctx context.Context, version string) (runtime.EngineVersion, error)
+}
+
+// GamesList renders GET /_gm/games.
+func (h *AdminConsoleHandlers) GamesList() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.games == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
+ return
+ }
+ page := parsePositiveQueryInt(c.Query("page"), 1)
+ pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
+ result, err := h.games.ListAdminGames(c.Request.Context(), page, pageSize)
+ if err != nil {
+ h.logger.Error("admin console: list games", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load games.", "bad", "/_gm/")
+ return
+ }
+ h.render(c, http.StatusOK, "games", "games", "Games", toGamesListData(result))
+ }
+}
+
+// GameCreate handles POST /_gm/games — create a public game.
+func (h *AdminConsoleHandlers) GameCreate() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.games == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
+ return
+ }
+ enrollmentEndsAt, err := parseConsoleDateTime(c.PostForm("enrollment_ends_at"))
+ if err != nil {
+ h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "Enrollment end must be a valid date/time.", "bad", "/_gm/games")
+ return
+ }
+ game, err := h.games.CreateGame(c.Request.Context(), lobby.CreateGameInput{
+ OwnerUserID: nil,
+ Visibility: lobby.VisibilityPublic,
+ GameName: strings.TrimSpace(c.PostForm("game_name")),
+ Description: strings.TrimSpace(c.PostForm("description")),
+ MinPlayers: formInt32(c, "min_players"),
+ MaxPlayers: formInt32(c, "max_players"),
+ StartGapHours: formInt32(c, "start_gap_hours"),
+ StartGapPlayers: formInt32(c, "start_gap_players"),
+ EnrollmentEndsAt: enrollmentEndsAt,
+ TurnSchedule: strings.TrimSpace(c.PostForm("turn_schedule")),
+ TargetEngineVersion: strings.TrimSpace(c.PostForm("target_engine_version")),
+ })
+ if err != nil {
+ if errors.Is(err, lobby.ErrInvalidInput) {
+ h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "The game could not be created: check the fields.", "bad", "/_gm/games")
+ return
+ }
+ h.logger.Error("admin console: create game", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "games", "Create failed", "Failed to create the game.", "bad", "/_gm/games")
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/_gm/games/"+game.GameID.String())
+ }
+}
+
+// GameDetail renders GET /_gm/games/:game_id with the runtime snapshot.
+func (h *AdminConsoleHandlers) GameDetail() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.games == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
+ return
+ }
+ gameID, ok := parseGameIDParam(c)
+ if !ok {
+ return
+ }
+ game, err := h.games.GetGame(c.Request.Context(), gameID)
+ if err != nil {
+ if errors.Is(err, lobby.ErrNotFound) {
+ h.renderMessage(c, http.StatusNotFound, "games", "Game not found", "No such game.", "bad", "/_gm/games")
+ return
+ }
+ h.logger.Error("admin console: get game", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "games", "Games", "Failed to load the game.", "bad", "/_gm/games")
+ return
+ }
+
+ var runtimeRecord *runtime.RuntimeRecord
+ if h.runtime != nil {
+ if record, rtErr := h.runtime.GetRuntime(c.Request.Context(), gameID); rtErr == nil {
+ runtimeRecord = &record
+ }
+ }
+ h.render(c, http.StatusOK, "game_detail", "games", game.GameName, toGameDetailData(game, runtimeRecord))
+ }
+}
+
+// GameForceStart handles POST /_gm/games/:game_id/force-start.
+func (h *AdminConsoleHandlers) GameForceStart() gin.HandlerFunc {
+ return h.gameAction("force-start", func(ctx context.Context, gameID uuid.UUID) error {
+ _, err := h.games.AdminForceStart(ctx, gameID)
+ return err
+ })
+}
+
+// GameForceStop handles POST /_gm/games/:game_id/force-stop.
+func (h *AdminConsoleHandlers) GameForceStop() gin.HandlerFunc {
+ return h.gameAction("force-stop", func(ctx context.Context, gameID uuid.UUID) error {
+ _, err := h.games.AdminForceStop(ctx, gameID)
+ return err
+ })
+}
+
+// GameBanMember handles POST /_gm/games/:game_id/ban-member.
+func (h *AdminConsoleHandlers) GameBanMember() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.games == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
+ return
+ }
+ gameID, ok := parseGameIDParam(c)
+ if !ok {
+ return
+ }
+ back := "/_gm/games/" + gameID.String()
+ userID, err := uuid.Parse(strings.TrimSpace(c.PostForm("user_id")))
+ if err != nil {
+ h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "User ID must be a valid UUID.", "bad", back)
+ return
+ }
+ if _, err := h.games.AdminBanMember(c.Request.Context(), gameID, userID, strings.TrimSpace(c.PostForm("reason"))); err != nil {
+ h.logger.Error("admin console: ban member", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "games", "Ban failed", "Failed to ban the member.", "bad", back)
+ return
+ }
+ c.Redirect(http.StatusSeeOther, back)
+ }
+}
+
+// RuntimeRestart handles POST /_gm/games/:game_id/runtime/restart.
+func (h *AdminConsoleHandlers) RuntimeRestart() gin.HandlerFunc {
+ return h.runtimeAction("restart", func(ctx context.Context, gameID uuid.UUID) error {
+ _, err := h.runtime.AdminRestart(ctx, gameID)
+ return err
+ })
+}
+
+// RuntimeForceNextTurn handles POST /_gm/games/:game_id/runtime/force-next-turn.
+func (h *AdminConsoleHandlers) RuntimeForceNextTurn() gin.HandlerFunc {
+ return h.runtimeAction("force-next-turn", func(ctx context.Context, gameID uuid.UUID) error {
+ _, err := h.runtime.AdminForceNextTurn(ctx, gameID)
+ return err
+ })
+}
+
+// RuntimePatch handles POST /_gm/games/:game_id/runtime/patch.
+func (h *AdminConsoleHandlers) RuntimePatch() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.runtime == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
+ return
+ }
+ gameID, ok := parseGameIDParam(c)
+ if !ok {
+ return
+ }
+ back := "/_gm/games/" + gameID.String()
+ target := strings.TrimSpace(c.PostForm("target_version"))
+ if target == "" {
+ h.renderMessage(c, http.StatusBadRequest, "games", "Invalid input", "A target version is required.", "bad", back)
+ return
+ }
+ if _, err := h.runtime.AdminPatch(c.Request.Context(), gameID, target); err != nil {
+ h.logger.Error("admin console: runtime patch", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "games", "Patch failed", "Failed to patch the runtime.", "bad", back)
+ return
+ }
+ c.Redirect(http.StatusSeeOther, back)
+ }
+}
+
+// gameAction is the shared shape for game-state POST actions that take only the
+// game id and redirect back to the detail page.
+func (h *AdminConsoleHandlers) gameAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.games == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "games", "Games", "Game administration is not available.", "bad", "/_gm/")
+ return
+ }
+ gameID, ok := parseGameIDParam(c)
+ if !ok {
+ return
+ }
+ back := "/_gm/games/" + gameID.String()
+ if err := run(c.Request.Context(), gameID); err != nil {
+ h.logger.Error("admin console: game "+label, zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The "+label+" action failed.", "bad", back)
+ return
+ }
+ c.Redirect(http.StatusSeeOther, back)
+ }
+}
+
+// runtimeAction is the shared shape for runtime POST actions that take only the
+// game id and redirect back to the detail page.
+func (h *AdminConsoleHandlers) runtimeAction(label string, run func(context.Context, uuid.UUID) error) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.runtime == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "games", "Runtime", "Runtime administration is not available.", "bad", "/_gm/games")
+ return
+ }
+ gameID, ok := parseGameIDParam(c)
+ if !ok {
+ return
+ }
+ back := "/_gm/games/" + gameID.String()
+ if err := run(c.Request.Context(), gameID); err != nil {
+ h.logger.Error("admin console: runtime "+label, zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "games", "Action failed", "The runtime "+label+" action failed.", "bad", back)
+ return
+ }
+ c.Redirect(http.StatusSeeOther, back)
+ }
+}
+
+// EngineVersionsList renders GET /_gm/engine-versions.
+func (h *AdminConsoleHandlers) EngineVersionsList() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.engineVersions == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
+ return
+ }
+ items, err := h.engineVersions.List(c.Request.Context())
+ if err != nil {
+ h.logger.Error("admin console: list engine versions", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Engine versions", "Failed to load engine versions.", "bad", "/_gm/")
+ return
+ }
+ h.render(c, http.StatusOK, "engine_versions", "games", "Engine versions", toEngineVersionsData(items))
+ }
+}
+
+// EngineVersionRegister handles POST /_gm/engine-versions.
+func (h *AdminConsoleHandlers) EngineVersionRegister() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.engineVersions == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
+ return
+ }
+ enabled := c.PostForm("enabled") == "true"
+ _, err := h.engineVersions.Register(c.Request.Context(), runtime.RegisterInput{
+ Version: strings.TrimSpace(c.PostForm("version")),
+ ImageRef: strings.TrimSpace(c.PostForm("image_ref")),
+ Enabled: &enabled,
+ })
+ if err != nil {
+ if errors.Is(err, runtime.ErrInvalidInput) || errors.Is(err, runtime.ErrEngineVersionTaken) {
+ h.renderMessage(c, http.StatusBadRequest, "engine-versions", "Invalid input", "The version could not be registered (invalid semver, missing image, or duplicate).", "bad", "/_gm/engine-versions")
+ return
+ }
+ h.logger.Error("admin console: register engine version", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Register failed", "Failed to register the engine version.", "bad", "/_gm/engine-versions")
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
+ }
+}
+
+// EngineVersionDisable handles POST /_gm/engine-versions/:version/disable.
+func (h *AdminConsoleHandlers) EngineVersionDisable() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.engineVersions == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "engine-versions", "Engine versions", "Engine-version administration is not available.", "bad", "/_gm/")
+ return
+ }
+ version := strings.TrimSpace(c.Param("version"))
+ if _, err := h.engineVersions.Disable(c.Request.Context(), version); err != nil {
+ h.logger.Error("admin console: disable engine version", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "engine-versions", "Disable failed", "Failed to disable the engine version.", "bad", "/_gm/engine-versions")
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/_gm/engine-versions")
+ }
+}
+
+// formInt32 reads a non-negative int32 form field, defaulting to 0.
+func formInt32(c *gin.Context, name string) int32 {
+ parsed, err := strconv.Atoi(strings.TrimSpace(c.PostForm(name)))
+ if err != nil || parsed < 0 {
+ return 0
+ }
+ return int32(parsed)
+}
+
+// parseConsoleDateTime parses the value of an
+// (or an RFC 3339 timestamp) as UTC.
+func parseConsoleDateTime(raw string) (time.Time, error) {
+ raw = strings.TrimSpace(raw)
+ for _, layout := range []string{"2006-01-02T15:04", "2006-01-02T15:04:05", time.RFC3339} {
+ if t, err := time.ParseInLocation(layout, raw, time.UTC); err == nil {
+ return t.UTC(), nil
+ }
+ }
+ return time.Time{}, errors.New("invalid date/time")
+}
+
+// toGamesListData maps a game page into the games list view model.
+func toGamesListData(page lobby.GamePage) adminconsole.GamesListData {
+ data := adminconsole.GamesListData{
+ Items: make([]adminconsole.GameRow, 0, len(page.Items)),
+ Page: page.Page,
+ PageSize: page.PageSize,
+ Total: page.Total,
+ PrevPage: page.Page - 1,
+ NextPage: page.Page + 1,
+ HasPrev: page.Page > 1,
+ HasNext: page.Page*page.PageSize < page.Total,
+ }
+ for _, game := range page.Items {
+ data.Items = append(data.Items, adminconsole.GameRow{
+ GameID: game.GameID.String(),
+ GameName: game.GameName,
+ Visibility: game.Visibility,
+ Status: game.Status,
+ Owner: ownerLabel(game.OwnerUserID),
+ Players: strconv.Itoa(int(game.MinPlayers)) + "–" + strconv.Itoa(int(game.MaxPlayers)),
+ TurnSchedule: game.TurnSchedule,
+ CreatedAt: fmtConsoleTime(game.CreatedAt),
+ })
+ }
+ return data
+}
+
+// toGameDetailData maps a game record and optional runtime record into the
+// detail view model.
+func toGameDetailData(game lobby.GameRecord, rec *runtime.RuntimeRecord) adminconsole.GameDetailData {
+ data := adminconsole.GameDetailData{
+ GameID: game.GameID.String(),
+ GameName: game.GameName,
+ Description: game.Description,
+ Visibility: game.Visibility,
+ Status: game.Status,
+ Owner: ownerLabel(game.OwnerUserID),
+ MinPlayers: game.MinPlayers,
+ MaxPlayers: game.MaxPlayers,
+ StartGapHours: game.StartGapHours,
+ StartGapPlayers: game.StartGapPlayers,
+ TurnSchedule: game.TurnSchedule,
+ TargetEngineVersion: game.TargetEngineVersion,
+ EnrollmentEndsAt: fmtConsoleTime(game.EnrollmentEndsAt),
+ CreatedAt: fmtConsoleTime(game.CreatedAt),
+ StartedAt: fmtConsoleTimePtr(game.StartedAt),
+ FinishedAt: fmtConsoleTimePtr(game.FinishedAt),
+ }
+ if rec != nil {
+ data.HasRuntime = true
+ data.RuntimeStatus = rec.Status
+ data.CurrentEngineVersion = rec.CurrentEngineVersion
+ data.EngineHealth = rec.EngineHealth
+ data.CurrentTurn = rec.CurrentTurn
+ data.NextGenerationAt = fmtConsoleTimePtr(rec.NextGenerationAt)
+ data.Paused = rec.Paused
+ }
+ return data
+}
+
+// toEngineVersionsData maps engine versions into the registry view model.
+func toEngineVersionsData(items []runtime.EngineVersion) adminconsole.EngineVersionsData {
+ data := adminconsole.EngineVersionsData{Items: make([]adminconsole.EngineVersionRow, 0, len(items))}
+ for _, v := range items {
+ data.Items = append(data.Items, adminconsole.EngineVersionRow{
+ Version: v.Version,
+ ImageRef: v.ImageRef,
+ Enabled: v.Enabled,
+ CreatedAt: fmtConsoleTime(v.CreatedAt),
+ })
+ }
+ return data
+}
+
+// ownerLabel renders an optional owner id; public games have no owner.
+func ownerLabel(ownerID *uuid.UUID) string {
+ if ownerID == nil {
+ return "—"
+ }
+ return ownerID.String()
+}
diff --git a/backend/internal/server/handlers_admin_console_games_test.go b/backend/internal/server/handlers_admin_console_games_test.go
new file mode 100644
index 0000000..3e31f42
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_games_test.go
@@ -0,0 +1,353 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/lobby"
+ "galaxy/backend/internal/runtime"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+type fakeGameAdmin struct {
+ page lobby.GamePage
+ game lobby.GameRecord
+ getErr error
+ created lobby.CreateGameInput
+
+ createCalls int
+ forceStartCalls int
+ forceStopCalls int
+ banCalls int
+ lastBanUser uuid.UUID
+ lastBanReason string
+}
+
+func (f *fakeGameAdmin) ListAdminGames(context.Context, int, int) (lobby.GamePage, error) {
+ return f.page, nil
+}
+func (f *fakeGameAdmin) GetGame(context.Context, uuid.UUID) (lobby.GameRecord, error) {
+ return f.game, f.getErr
+}
+func (f *fakeGameAdmin) CreateGame(_ context.Context, in lobby.CreateGameInput) (lobby.GameRecord, error) {
+ f.createCalls++
+ f.created = in
+ return f.game, nil
+}
+func (f *fakeGameAdmin) AdminForceStart(context.Context, uuid.UUID) (lobby.GameRecord, error) {
+ f.forceStartCalls++
+ return f.game, nil
+}
+func (f *fakeGameAdmin) AdminForceStop(context.Context, uuid.UUID) (lobby.GameRecord, error) {
+ f.forceStopCalls++
+ return f.game, nil
+}
+func (f *fakeGameAdmin) AdminBanMember(_ context.Context, _, userID uuid.UUID, reason string) (lobby.Membership, error) {
+ f.banCalls++
+ f.lastBanUser = userID
+ f.lastBanReason = reason
+ return lobby.Membership{}, nil
+}
+
+type fakeRuntimeAdmin struct {
+ record runtime.RuntimeRecord
+ getErr error
+ restartCalls int
+ forceNextCalls int
+ patchCalls int
+ lastPatchVersion string
+}
+
+func (f *fakeRuntimeAdmin) GetRuntime(context.Context, uuid.UUID) (runtime.RuntimeRecord, error) {
+ return f.record, f.getErr
+}
+func (f *fakeRuntimeAdmin) AdminRestart(context.Context, uuid.UUID) (runtime.OperationLog, error) {
+ f.restartCalls++
+ return runtime.OperationLog{}, nil
+}
+func (f *fakeRuntimeAdmin) AdminPatch(_ context.Context, _ uuid.UUID, target string) (runtime.OperationLog, error) {
+ f.patchCalls++
+ f.lastPatchVersion = target
+ return runtime.OperationLog{}, nil
+}
+func (f *fakeRuntimeAdmin) AdminForceNextTurn(context.Context, uuid.UUID) (runtime.OperationLog, error) {
+ f.forceNextCalls++
+ return runtime.OperationLog{}, nil
+}
+
+type fakeEngineVersionAdmin struct {
+ list []runtime.EngineVersion
+ registered runtime.RegisterInput
+ registerCalls int
+ disableCalls int
+ lastDisabled string
+}
+
+func (f *fakeEngineVersionAdmin) List(context.Context) ([]runtime.EngineVersion, error) {
+ return f.list, nil
+}
+func (f *fakeEngineVersionAdmin) Register(_ context.Context, in runtime.RegisterInput) (runtime.EngineVersion, error) {
+ f.registerCalls++
+ f.registered = in
+ return runtime.EngineVersion{}, nil
+}
+func (f *fakeEngineVersionAdmin) Disable(_ context.Context, version string) (runtime.EngineVersion, error) {
+ f.disableCalls++
+ f.lastDisabled = version
+ return runtime.EngineVersion{}, nil
+}
+
+func newGamesConsoleRouter(t *testing.T, games GameAdmin, rt RuntimeAdmin, ev EngineVersionAdmin) (http.Handler, *adminconsole.CSRF) {
+ t.Helper()
+ csrf := adminconsole.NewCSRF([]byte("test-key"))
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
+ CSRF: csrf, Games: games, Runtime: rt, EngineVersions: ev,
+ }),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+ return handler, csrf
+}
+
+func consoleGet(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func consolePost(t *testing.T, router http.Handler, path, form string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan"+path, strings.NewReader(form))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://galaxy.lan")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func TestConsoleGamesList(t *testing.T) {
+ games := &fakeGameAdmin{page: lobby.GamePage{
+ Items: []lobby.GameRecord{{GameID: uuid.New(), GameName: "Nova", Visibility: "public", Status: "enrollment_open"}},
+ Page: 1, PageSize: 50, Total: 1,
+ }}
+ router, _ := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/games")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"Nova", "public", "enrollment_open", "Create public game"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("games list missing %q", want)
+ }
+ }
+}
+
+func TestConsoleGameDetailWithRuntime(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova", Status: "running"}}
+ rt := &fakeRuntimeAdmin{record: runtime.RuntimeRecord{GameID: id, Status: "running", CurrentEngineVersion: "0.1.0", CurrentTurn: 7}}
+ router, csrf := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consoleGet(t, router, "/_gm/games/"+id.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"Nova", "Force start", "Force stop", "0.1.0", "Patch", "Ban member", csrf.Token("ops")} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("game detail missing %q", want)
+ }
+ }
+}
+
+func TestConsoleGameDetailNoRuntime(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id, GameName: "Nova"}}
+ rt := &fakeRuntimeAdmin{getErr: errors.New("not found")}
+ router, _ := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consoleGet(t, router, "/_gm/games/"+id.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "No runtime record") {
+ t.Error("expected a no-runtime note")
+ }
+}
+
+func TestConsoleGameCreate(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ form := "_csrf=" + csrf.Token("ops") +
+ "&game_name=Nova&description=d&min_players=2&max_players=8&start_gap_hours=0&start_gap_players=0" +
+ "&enrollment_ends_at=2030-01-02T15:04&turn_schedule=@every+24h&target_engine_version=0.1.0"
+ rec := consolePost(t, router, "/_gm/games", form)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
+ }
+ if got := rec.Header().Get("Location"); got != "/_gm/games/"+id.String() {
+ t.Errorf("redirect = %q, want detail page", got)
+ }
+ if games.createCalls != 1 {
+ t.Fatalf("CreateGame called %d times, want 1", games.createCalls)
+ }
+ if games.created.Visibility != lobby.VisibilityPublic {
+ t.Errorf("visibility = %q, want public", games.created.Visibility)
+ }
+ if games.created.GameName != "Nova" {
+ t.Errorf("game name = %q", games.created.GameName)
+ }
+ if games.created.EnrollmentEndsAt.Year() != 2030 {
+ t.Errorf("enrollment year = %d, want 2030", games.created.EnrollmentEndsAt.Year())
+ }
+ if games.created.OwnerUserID != nil {
+ t.Error("public game must have a nil owner")
+ }
+}
+
+func TestConsoleGameForceStart(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if games.forceStartCalls != 1 {
+ t.Errorf("AdminForceStart called %d times, want 1", games.forceStartCalls)
+ }
+}
+
+func TestConsoleGameForceStartRejectsBadCSRF(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ router, _ := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/force-start", "")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rec.Code)
+ }
+ if games.forceStartCalls != 0 {
+ t.Error("force-start must not run without a CSRF token")
+ }
+}
+
+func TestConsoleGameBanMember(t *testing.T) {
+ gameID := uuid.New()
+ target := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ form := "_csrf=" + csrf.Token("ops") + "&user_id=" + target.String() + "&reason=cheating"
+ rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", form)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
+ }
+ if games.banCalls != 1 || games.lastBanUser != target || games.lastBanReason != "cheating" {
+ t.Errorf("ban recorded %d user=%s reason=%q", games.banCalls, games.lastBanUser, games.lastBanReason)
+ }
+}
+
+func TestConsoleGameBanMemberRejectsBadUUID(t *testing.T) {
+ gameID := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: gameID}}
+ router, csrf := newGamesConsoleRouter(t, games, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+gameID.String()+"/ban-member", "_csrf="+csrf.Token("ops")+"&user_id=not-a-uuid")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if games.banCalls != 0 {
+ t.Error("ban must not run with an invalid user id")
+ }
+}
+
+func TestConsoleRuntimePatch(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ rt := &fakeRuntimeAdmin{}
+ router, csrf := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops")+"&target_version=0.1.1")
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if rt.patchCalls != 1 || rt.lastPatchVersion != "0.1.1" {
+ t.Errorf("patch recorded %d version=%q", rt.patchCalls, rt.lastPatchVersion)
+ }
+}
+
+func TestConsoleRuntimePatchMissingVersion(t *testing.T) {
+ id := uuid.New()
+ games := &fakeGameAdmin{game: lobby.GameRecord{GameID: id}}
+ rt := &fakeRuntimeAdmin{}
+ router, csrf := newGamesConsoleRouter(t, games, rt, nil)
+
+ rec := consolePost(t, router, "/_gm/games/"+id.String()+"/runtime/patch", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if rt.patchCalls != 0 {
+ t.Error("patch must not run without a target version")
+ }
+}
+
+func TestConsoleEngineVersions(t *testing.T) {
+ ev := &fakeEngineVersionAdmin{list: []runtime.EngineVersion{{Version: "0.1.0", ImageRef: "img:0.1.0", Enabled: true}}}
+ router, csrf := newGamesConsoleRouter(t, nil, nil, ev)
+
+ rec := consoleGet(t, router, "/_gm/engine-versions")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"0.1.0", "img:0.1.0", "Register version", "Disable"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("engine versions page missing %q", want)
+ }
+ }
+
+ rec = consolePost(t, router, "/_gm/engine-versions", "_csrf="+csrf.Token("ops")+"&version=0.2.0&image_ref=img:0.2.0&enabled=true")
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("register status = %d, want 303", rec.Code)
+ }
+ if ev.registerCalls != 1 || ev.registered.Version != "0.2.0" || ev.registered.Enabled == nil || !*ev.registered.Enabled {
+ t.Errorf("register recorded %d version=%q enabled=%v", ev.registerCalls, ev.registered.Version, ev.registered.Enabled)
+ }
+
+ rec = consolePost(t, router, "/_gm/engine-versions/0.1.0/disable", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("disable status = %d, want 303", rec.Code)
+ }
+ if ev.disableCalls != 1 || ev.lastDisabled != "0.1.0" {
+ t.Errorf("disable recorded %d version=%q", ev.disableCalls, ev.lastDisabled)
+ }
+}
+
+func TestConsoleGamesUnavailable(t *testing.T) {
+ router, _ := newGamesConsoleRouter(t, nil, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/games")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", rec.Code)
+ }
+}
diff --git a/backend/internal/server/handlers_admin_console_mail.go b/backend/internal/server/handlers_admin_console_mail.go
new file mode 100644
index 0000000..cfee71a
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_mail.go
@@ -0,0 +1,327 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/diplomail"
+ "galaxy/backend/internal/mail"
+ "galaxy/backend/internal/notification"
+ "galaxy/backend/internal/server/clientip"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+// MailAdmin is the subset of the mail service the console uses.
+type MailAdmin interface {
+ AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
+ AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
+ AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
+ AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
+ AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
+}
+
+// NotificationAdmin is the subset of the notification service the console uses.
+type NotificationAdmin interface {
+ AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
+ AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
+ AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
+}
+
+// DiplomailAdmin is the subset of the diplomail service the console uses.
+type DiplomailAdmin interface {
+ SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
+}
+
+const consoleSnapshotPageSize = 50
+
+// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
+func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.mail == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
+ return
+ }
+ page := parsePositiveQueryInt(c.Query("page"), 1)
+ pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
+ ctx := c.Request.Context()
+
+ deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
+ if err != nil {
+ h.logger.Error("admin console: list deliveries", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
+ return
+ }
+ dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
+ if err != nil {
+ h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
+ return
+ }
+ h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
+ }
+}
+
+// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
+func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.mail == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
+ return
+ }
+ deliveryID, ok := parseConsoleDeliveryID(c, h)
+ if !ok {
+ return
+ }
+ ctx := c.Request.Context()
+ delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
+ if err != nil {
+ if errors.Is(err, mail.ErrDeliveryNotFound) {
+ h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
+ return
+ }
+ h.logger.Error("admin console: get delivery", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
+ return
+ }
+ attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
+ if err != nil {
+ h.logger.Error("admin console: list attempts", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
+ return
+ }
+ h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
+ }
+}
+
+// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
+func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.mail == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
+ return
+ }
+ deliveryID, ok := parseConsoleDeliveryID(c, h)
+ if !ok {
+ return
+ }
+ back := "/_gm/mail/deliveries/" + deliveryID.String()
+ if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
+ h.logger.Error("admin console: resend delivery", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
+ return
+ }
+ c.Redirect(http.StatusSeeOther, back)
+ }
+}
+
+// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
+// and malformed intents on one overview page.
+func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.notifications == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
+ return
+ }
+ ctx := c.Request.Context()
+ notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
+ if err != nil {
+ h.logger.Error("admin console: list notifications", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
+ return
+ }
+ dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
+ if err != nil {
+ h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
+ return
+ }
+ malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
+ if err != nil {
+ h.logger.Error("admin console: list malformed intents", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
+ return
+ }
+ h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
+ }
+}
+
+// BroadcastForm renders GET /_gm/broadcast.
+func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.diplomail == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
+ return
+ }
+ h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
+ }
+}
+
+// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
+func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.diplomail == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
+ return
+ }
+ username, _ := basicauth.UsernameFromContext(c.Request.Context())
+ gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
+ if err != nil {
+ h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
+ return
+ }
+ _, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
+ CallerUsername: username,
+ Scope: strings.TrimSpace(c.PostForm("scope")),
+ GameIDs: gameIDs,
+ RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
+ Subject: strings.TrimSpace(c.PostForm("subject")),
+ Body: c.PostForm("body"),
+ SenderIP: clientip.ExtractSourceIP(c),
+ })
+ if err != nil {
+ if errors.Is(err, diplomail.ErrInvalidInput) {
+ h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
+ return
+ }
+ h.logger.Error("admin console: broadcast", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
+ return
+ }
+ h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
+ }
+}
+
+// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
+// console message page on failure.
+func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
+ parsed, err := uuid.Parse(c.Param("delivery_id"))
+ if err != nil {
+ h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
+ return uuid.Nil, false
+ }
+ return parsed, true
+}
+
+// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
+func parseGameIDList(raw string) ([]uuid.UUID, error) {
+ fields := strings.Split(raw, ",")
+ ids := make([]uuid.UUID, 0, len(fields))
+ for _, field := range fields {
+ field = strings.TrimSpace(field)
+ if field == "" {
+ continue
+ }
+ parsed, err := uuid.Parse(field)
+ if err != nil {
+ return nil, err
+ }
+ ids = append(ids, parsed)
+ }
+ return ids, nil
+}
+
+func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
+ data := adminconsole.MailData{
+ Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
+ DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
+ Page: deliveries.Page,
+ PageSize: deliveries.PageSize,
+ Total: deliveries.Total,
+ PrevPage: deliveries.Page - 1,
+ NextPage: deliveries.Page + 1,
+ HasPrev: deliveries.Page > 1,
+ HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
+ }
+ for _, d := range deliveries.Items {
+ data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
+ DeliveryID: d.DeliveryID.String(),
+ Template: d.TemplateID,
+ Status: d.Status,
+ Attempts: d.Attempts,
+ NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
+ Created: fmtConsoleTime(d.CreatedAt),
+ })
+ }
+ for _, d := range dead.Items {
+ data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
+ DeliveryID: d.DeliveryID.String(),
+ Reason: d.Reason,
+ Archived: fmtConsoleTime(d.ArchivedAt),
+ })
+ }
+ return data
+}
+
+func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
+ detail := adminconsole.MailDeliveryDetail{
+ DeliveryID: d.DeliveryID.String(),
+ Template: d.TemplateID,
+ Status: d.Status,
+ Attempts: d.Attempts,
+ NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
+ LastError: d.LastError,
+ Created: fmtConsoleTime(d.CreatedAt),
+ Sent: fmtConsoleTimePtr(d.SentAt),
+ DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
+ CanResend: d.Status != mail.StatusSent,
+ AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
+ }
+ for _, a := range attempts {
+ detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
+ AttemptNo: a.AttemptNo,
+ Outcome: a.Outcome,
+ Started: fmtConsoleTime(a.StartedAt),
+ Finished: fmtConsoleTimePtr(a.FinishedAt),
+ Error: a.Error,
+ })
+ }
+ return detail
+}
+
+func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
+ data := adminconsole.NotificationsData{
+ Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
+ DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
+ Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
+ }
+ for _, n := range notifications.Items {
+ data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
+ NotificationID: n.NotificationID.String(),
+ Kind: n.Kind,
+ UserID: optionalUUID(n.UserID),
+ Created: fmtConsoleTime(n.CreatedAt),
+ })
+ }
+ for _, d := range dead.Items {
+ data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
+ NotificationID: d.NotificationID.String(),
+ RouteID: d.RouteID.String(),
+ Reason: d.Reason,
+ Archived: fmtConsoleTime(d.ArchivedAt),
+ })
+ }
+ for _, m := range malformed.Items {
+ data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
+ ID: m.ID.String(),
+ Reason: m.Reason,
+ Received: fmtConsoleTime(m.ReceivedAt),
+ })
+ }
+ return data
+}
+
+// optionalUUID renders a nullable user id; system-scoped rows have none.
+func optionalUUID(id *uuid.UUID) string {
+ if id == nil {
+ return "—"
+ }
+ return id.String()
+}
diff --git a/backend/internal/server/handlers_admin_console_mail_test.go b/backend/internal/server/handlers_admin_console_mail_test.go
new file mode 100644
index 0000000..449e82f
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_mail_test.go
@@ -0,0 +1,242 @@
+package server
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/diplomail"
+ "galaxy/backend/internal/mail"
+ "galaxy/backend/internal/notification"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+type fakeMailAdmin struct {
+ deliveries mail.AdminListDeliveriesPage
+ dead mail.AdminListDeadLettersPage
+ delivery mail.Delivery
+ getErr error
+ attempts []mail.Attempt
+ resendCalls int
+}
+
+func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) {
+ return f.deliveries, nil
+}
+func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
+ return f.delivery, f.getErr
+}
+func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) {
+ return f.attempts, nil
+}
+func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
+ f.resendCalls++
+ return f.delivery, nil
+}
+func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) {
+ return f.dead, nil
+}
+
+type fakeNotificationAdmin struct {
+ notifications notification.AdminListNotificationsPage
+ dead notification.AdminListDeadLettersPage
+ malformed notification.AdminListMalformedPage
+}
+
+func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) {
+ return f.notifications, nil
+}
+func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) {
+ return f.dead, nil
+}
+func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) {
+ return f.malformed, nil
+}
+
+type fakeDiplomailAdmin struct {
+ total int
+ err error
+ broadcastCalls int
+ last diplomail.SendMultiGameBroadcastInput
+}
+
+func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) {
+ f.broadcastCalls++
+ f.last = in
+ if f.err != nil {
+ return nil, 0, f.err
+ }
+ return nil, f.total, nil
+}
+
+func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) {
+ t.Helper()
+ csrf := adminconsole.NewCSRF([]byte("test-key"))
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+ return handler, csrf
+}
+
+func TestConsoleMailPage(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{
+ deliveries: mail.AdminListDeliveriesPage{
+ Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}},
+ Page: 1, PageSize: 50, Total: 1,
+ },
+ dead: mail.AdminListDeadLettersPage{
+ Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}},
+ },
+ }
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("mail page missing %q", want)
+ }
+ }
+}
+
+func TestConsoleMailDeliveryDetail(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{
+ delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2},
+ attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}},
+ }
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("delivery detail missing %q", want)
+ }
+ }
+}
+
+func TestConsoleMailDeliveryDetailNotFound(t *testing.T) {
+ m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound}
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String())
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+}
+
+func TestConsoleMailResend(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
+ router, csrf := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if m.resendCalls != 1 {
+ t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls)
+ }
+}
+
+func TestConsoleMailResendRejectsBadCSRF(t *testing.T) {
+ id := uuid.New()
+ m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
+ router, _ := mailConsoleRouter(t, m, nil, nil)
+
+ rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rec.Code)
+ }
+ if m.resendCalls != 0 {
+ t.Error("resend must not run without a CSRF token")
+ }
+}
+
+func TestConsoleNotificationsPage(t *testing.T) {
+ n := &fakeNotificationAdmin{
+ notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}},
+ dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}},
+ malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}},
+ }
+ router, _ := mailConsoleRouter(t, nil, n, nil)
+
+ rec := consoleGet(t, router, "/_gm/notifications")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("notifications page missing %q", want)
+ }
+ }
+}
+
+func TestConsoleBroadcastForm(t *testing.T) {
+ router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{})
+
+ rec := consoleGet(t, router, "/_gm/broadcast")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Send broadcast") {
+ t.Error("broadcast form missing")
+ }
+}
+
+func TestConsoleBroadcastSend(t *testing.T) {
+ d := &fakeDiplomailAdmin{total: 5}
+ router, csrf := mailConsoleRouter(t, nil, nil, d)
+
+ form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello"
+ rec := consolePost(t, router, "/_gm/broadcast", form)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "5 recipients") {
+ t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String())
+ }
+ if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" {
+ t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls)
+ }
+}
+
+func TestConsoleBroadcastSendBadGameIDs(t *testing.T) {
+ d := &fakeDiplomailAdmin{}
+ router, csrf := mailConsoleRouter(t, nil, nil, d)
+
+ form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello"
+ rec := consolePost(t, router, "/_gm/broadcast", form)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if d.broadcastCalls != 0 {
+ t.Error("broadcast must not run with invalid game ids")
+ }
+}
+
+func TestConsoleMailUnavailable(t *testing.T) {
+ router, _ := mailConsoleRouter(t, nil, nil, nil)
+
+ rec := consoleGet(t, router, "/_gm/mail")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", rec.Code)
+ }
+}
diff --git a/backend/internal/server/handlers_admin_console_operators.go b/backend/internal/server/handlers_admin_console_operators.go
new file mode 100644
index 0000000..e02c36b
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_operators.go
@@ -0,0 +1,149 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+
+ "galaxy/backend/internal/admin"
+ "galaxy/backend/internal/adminconsole"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+// OperatorAdmin is the subset of the admin-account service the console uses.
+// *admin.Service satisfies it.
+type OperatorAdmin interface {
+ List(ctx context.Context) ([]admin.Admin, error)
+ Create(ctx context.Context, in admin.CreateInput) (admin.Admin, error)
+ Disable(ctx context.Context, username string) (admin.Admin, error)
+ Enable(ctx context.Context, username string) (admin.Admin, error)
+ ResetPassword(ctx context.Context, username, password string) (admin.Admin, error)
+}
+
+// OperatorsList renders GET /_gm/operators.
+func (h *AdminConsoleHandlers) OperatorsList() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.operators == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
+ return
+ }
+ admins, err := h.operators.List(c.Request.Context())
+ if err != nil {
+ h.logger.Error("admin console: list operators", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "operators", "Operators", "Failed to load operators.", "bad", "/_gm/")
+ return
+ }
+ h.render(c, http.StatusOK, "operators", "operators", "Operators", toOperatorsData(admins))
+ }
+}
+
+// OperatorCreate handles POST /_gm/operators.
+func (h *AdminConsoleHandlers) OperatorCreate() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.operators == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
+ return
+ }
+ _, err := h.operators.Create(c.Request.Context(), admin.CreateInput{
+ Username: strings.TrimSpace(c.PostForm("username")),
+ Password: c.PostForm("password"),
+ })
+ if err != nil {
+ switch {
+ case errors.Is(err, admin.ErrUsernameTaken):
+ h.renderMessage(c, http.StatusConflict, "operators", "Username taken", "That username is already in use.", "bad", "/_gm/operators")
+ case errors.Is(err, admin.ErrInvalidInput):
+ h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "Username and password are required.", "bad", "/_gm/operators")
+ default:
+ h.logger.Error("admin console: create operator", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "operators", "Create failed", "Failed to create the operator.", "bad", "/_gm/operators")
+ }
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/_gm/operators")
+ }
+}
+
+// OperatorDisable handles POST /_gm/operators/:username/disable.
+func (h *AdminConsoleHandlers) OperatorDisable() gin.HandlerFunc {
+ return h.operatorAction("disable", func(ctx context.Context, username string) error {
+ _, err := h.operators.Disable(ctx, username)
+ return err
+ })
+}
+
+// OperatorEnable handles POST /_gm/operators/:username/enable.
+func (h *AdminConsoleHandlers) OperatorEnable() gin.HandlerFunc {
+ return h.operatorAction("enable", func(ctx context.Context, username string) error {
+ _, err := h.operators.Enable(ctx, username)
+ return err
+ })
+}
+
+// OperatorResetPassword handles POST /_gm/operators/:username/reset-password.
+func (h *AdminConsoleHandlers) OperatorResetPassword() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.operators == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
+ return
+ }
+ username := c.Param("username")
+ password := c.PostForm("password")
+ if strings.TrimSpace(password) == "" {
+ h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "A new password is required.", "bad", "/_gm/operators")
+ return
+ }
+ if _, err := h.operators.ResetPassword(c.Request.Context(), username, password); err != nil {
+ if errors.Is(err, admin.ErrNotFound) {
+ h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
+ return
+ }
+ if errors.Is(err, admin.ErrInvalidInput) {
+ h.renderMessage(c, http.StatusBadRequest, "operators", "Invalid input", "The password was rejected.", "bad", "/_gm/operators")
+ return
+ }
+ h.logger.Error("admin console: reset operator password", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "operators", "Reset failed", "Failed to reset the password.", "bad", "/_gm/operators")
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/_gm/operators")
+ }
+}
+
+// operatorAction is the shared shape for operator POST actions that take only
+// the username and redirect back to the list.
+func (h *AdminConsoleHandlers) operatorAction(label string, run func(context.Context, string) error) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.operators == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "operators", "Operators", "Operator administration is not available.", "bad", "/_gm/")
+ return
+ }
+ if err := run(c.Request.Context(), c.Param("username")); err != nil {
+ if errors.Is(err, admin.ErrNotFound) {
+ h.renderMessage(c, http.StatusNotFound, "operators", "Operator not found", "No such operator.", "bad", "/_gm/operators")
+ return
+ }
+ h.logger.Error("admin console: operator "+label, zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "operators", "Action failed", "The "+label+" action failed.", "bad", "/_gm/operators")
+ return
+ }
+ c.Redirect(http.StatusSeeOther, "/_gm/operators")
+ }
+}
+
+// toOperatorsData maps admin accounts into the operators view model.
+func toOperatorsData(admins []admin.Admin) adminconsole.OperatorsData {
+ data := adminconsole.OperatorsData{Items: make([]adminconsole.OperatorRow, 0, len(admins))}
+ for _, a := range admins {
+ data.Items = append(data.Items, adminconsole.OperatorRow{
+ Username: a.Username,
+ CreatedAt: fmtConsoleTime(a.CreatedAt),
+ LastUsedAt: fmtConsoleTimePtr(a.LastUsedAt),
+ Disabled: a.DisabledAt != nil,
+ })
+ }
+ return data
+}
diff --git a/backend/internal/server/handlers_admin_console_operators_test.go b/backend/internal/server/handlers_admin_console_operators_test.go
new file mode 100644
index 0000000..c2f325b
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_operators_test.go
@@ -0,0 +1,166 @@
+package server
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+
+ "galaxy/backend/internal/admin"
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "go.uber.org/zap"
+)
+
+type fakeOperatorAdmin struct {
+ list []admin.Admin
+ createErr error
+
+ created admin.CreateInput
+ createCalls int
+ disableCalls int
+ enableCalls int
+ resetCalls int
+ lastResetUser string
+ lastResetPass string
+}
+
+func (f *fakeOperatorAdmin) List(context.Context) ([]admin.Admin, error) { return f.list, nil }
+func (f *fakeOperatorAdmin) Create(_ context.Context, in admin.CreateInput) (admin.Admin, error) {
+ f.createCalls++
+ f.created = in
+ if f.createErr != nil {
+ return admin.Admin{}, f.createErr
+ }
+ return admin.Admin{Username: in.Username}, nil
+}
+func (f *fakeOperatorAdmin) Disable(_ context.Context, username string) (admin.Admin, error) {
+ f.disableCalls++
+ return admin.Admin{Username: username}, nil
+}
+func (f *fakeOperatorAdmin) Enable(_ context.Context, username string) (admin.Admin, error) {
+ f.enableCalls++
+ return admin.Admin{Username: username}, nil
+}
+func (f *fakeOperatorAdmin) ResetPassword(_ context.Context, username, password string) (admin.Admin, error) {
+ f.resetCalls++
+ f.lastResetUser = username
+ f.lastResetPass = password
+ return admin.Admin{Username: username}, nil
+}
+
+func operatorsRouter(t *testing.T, operators OperatorAdmin) (http.Handler, *adminconsole.CSRF) {
+ t.Helper()
+ csrf := adminconsole.NewCSRF([]byte("test-key"))
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Operators: operators}),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+ return handler, csrf
+}
+
+func TestConsoleOperatorsList(t *testing.T) {
+ fake := &fakeOperatorAdmin{list: []admin.Admin{{Username: "root"}}}
+ router, _ := operatorsRouter(t, fake)
+
+ rec := consoleGet(t, router, "/_gm/operators")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ for _, want := range []string{"root", "Create operator", "Reset"} {
+ if !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("operators page missing %q", want)
+ }
+ }
+}
+
+func TestConsoleOperatorCreate(t *testing.T) {
+ fake := &fakeOperatorAdmin{}
+ router, csrf := operatorsRouter(t, fake)
+
+ rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=mod&password=s3cret")
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
+ }
+ if fake.createCalls != 1 || fake.created.Username != "mod" || fake.created.Password != "s3cret" {
+ t.Errorf("create recorded %d username=%q", fake.createCalls, fake.created.Username)
+ }
+}
+
+func TestConsoleOperatorCreateConflict(t *testing.T) {
+ fake := &fakeOperatorAdmin{createErr: admin.ErrUsernameTaken}
+ router, csrf := operatorsRouter(t, fake)
+
+ rec := consolePost(t, router, "/_gm/operators", "_csrf="+csrf.Token("ops")+"&username=root&password=x")
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("status = %d, want 409", rec.Code)
+ }
+}
+
+func TestConsoleOperatorDisableEnable(t *testing.T) {
+ fake := &fakeOperatorAdmin{}
+ router, csrf := operatorsRouter(t, fake)
+
+ if rec := consolePost(t, router, "/_gm/operators/root/disable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
+ t.Fatalf("disable status = %d, want 303", rec.Code)
+ }
+ if rec := consolePost(t, router, "/_gm/operators/root/enable", "_csrf="+csrf.Token("ops")); rec.Code != http.StatusSeeOther {
+ t.Fatalf("enable status = %d, want 303", rec.Code)
+ }
+ if fake.disableCalls != 1 || fake.enableCalls != 1 {
+ t.Errorf("disable=%d enable=%d, want 1/1", fake.disableCalls, fake.enableCalls)
+ }
+}
+
+func TestConsoleOperatorResetPassword(t *testing.T) {
+ fake := &fakeOperatorAdmin{}
+ router, csrf := operatorsRouter(t, fake)
+
+ rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops")+"&password=newpass")
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if fake.resetCalls != 1 || fake.lastResetUser != "root" || fake.lastResetPass != "newpass" {
+ t.Errorf("reset recorded %d user=%q", fake.resetCalls, fake.lastResetUser)
+ }
+}
+
+func TestConsoleOperatorResetPasswordMissing(t *testing.T) {
+ fake := &fakeOperatorAdmin{}
+ router, csrf := operatorsRouter(t, fake)
+
+ rec := consolePost(t, router, "/_gm/operators/root/reset-password", "_csrf="+csrf.Token("ops"))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if fake.resetCalls != 0 {
+ t.Error("reset must not run without a password")
+ }
+}
+
+func TestConsoleOperatorRejectsBadCSRF(t *testing.T) {
+ fake := &fakeOperatorAdmin{}
+ router, _ := operatorsRouter(t, fake)
+
+ rec := consolePost(t, router, "/_gm/operators/root/disable", "")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rec.Code)
+ }
+ if fake.disableCalls != 0 {
+ t.Error("disable must not run without a CSRF token")
+ }
+}
+
+func TestConsoleOperatorsUnavailable(t *testing.T) {
+ router, _ := operatorsRouter(t, nil)
+
+ rec := consoleGet(t, router, "/_gm/operators")
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", rec.Code)
+ }
+}
diff --git a/backend/internal/server/handlers_admin_console_test.go b/backend/internal/server/handlers_admin_console_test.go
new file mode 100644
index 0000000..0bf8926
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_test.go
@@ -0,0 +1,214 @@
+package server
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/opsstatus"
+ "galaxy/backend/internal/server/middleware/basicauth"
+
+ "github.com/gin-gonic/gin"
+ "go.uber.org/zap"
+)
+
+// fakeMonitor is a static opsstatus.Reader for dashboard rendering tests.
+type fakeMonitor struct {
+ snapshot opsstatus.Snapshot
+}
+
+func (f fakeMonitor) Collect(context.Context) opsstatus.Snapshot {
+ return f.snapshot
+}
+
+func newConsoleTestRouter(t *testing.T) http.Handler {
+ t.Helper()
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: adminconsole.NewCSRF([]byte("test-key"))}),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+ return handler
+}
+
+func TestAdminConsoleRequiresAuth(t *testing.T) {
+ router := newConsoleTestRouter(t)
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", rec.Code)
+ }
+ if got := rec.Header().Get("WWW-Authenticate"); !strings.Contains(got, "Basic") {
+ t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", got)
+ }
+}
+
+func TestAdminConsoleDashboardRenders(t *testing.T) {
+ router := newConsoleTestRouter(t)
+
+ for _, path := range []string{"/_gm", "/_gm/"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET %s status = %d, want 200; body=%s", path, rec.Code, rec.Body.String())
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Errorf("GET %s content-type = %q, want text/html", path, ct)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Dashboard") {
+ t.Errorf("GET %s body missing the dashboard heading", path)
+ }
+ if !strings.Contains(body, "ops") {
+ t.Errorf("GET %s body missing the operator name", path)
+ }
+ }
+}
+
+func TestAdminConsoleDashboardShowsMonitoring(t *testing.T) {
+ monitor := fakeMonitor{snapshot: opsstatus.Snapshot{
+ PostgresHealthy: true,
+ Runtimes: []opsstatus.StatusCount{{Status: "running", Count: 3}, {Status: "stopped", Count: 1}},
+ MailDeliveries: []opsstatus.StatusCount{{Status: "pending", Count: 2}},
+ NotificationRoutes: []opsstatus.StatusCount{{Status: "published", Count: 9}},
+ NotificationMalformed: 4,
+ Errors: []string{"notification route counts: boom"},
+ }}
+
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{
+ CSRF: adminconsole.NewCSRF([]byte("test-key")),
+ Monitor: monitor,
+ Ready: func() bool { return true },
+ }),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "Game runtimes", "running", "stopped",
+ "Mail deliveries", "pending",
+ "Notification routes", "published",
+ "Malformed notifications",
+ "notification route counts: boom",
+ "healthy",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("dashboard body missing %q", want)
+ }
+ }
+}
+
+func TestAdminConsoleDashboardWithoutMonitor(t *testing.T) {
+ router := newConsoleTestRouter(t) // no monitor wired
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "Monitoring is not wired") {
+ t.Error("dashboard without a monitor should note that monitoring is unavailable")
+ }
+}
+
+func TestAdminConsoleServesAsset(t *testing.T) {
+ router := newConsoleTestRouter(t)
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/assets/console.css", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("asset status = %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/css") {
+ t.Errorf("asset content-type = %q, want text/css", ct)
+ }
+}
+
+func TestAdminConsoleRequireCSRF(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ csrf := adminconsole.NewCSRF([]byte("test-key"))
+ console := NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf})
+
+ engine := gin.New()
+ engine.Use(func(c *gin.Context) {
+ c.Request = c.Request.WithContext(basicauth.WithUsername(c.Request.Context(), "ops"))
+ c.Next()
+ })
+ engine.Use(console.RequireCSRF())
+ engine.GET("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
+ engine.POST("/x", func(c *gin.Context) { c.Status(http.StatusOK) })
+
+ token := csrf.Token("ops")
+
+ cases := []struct {
+ name string
+ method string
+ form string
+ origin string
+ host string
+ want int
+ }{
+ {"get is a safe method", http.MethodGet, "", "", "galaxy.lan", http.StatusOK},
+ {"valid token, same origin", http.MethodPost, "_csrf=" + token, "https://galaxy.lan", "galaxy.lan", http.StatusOK},
+ {"valid token, no origin header", http.MethodPost, "_csrf=" + token, "", "galaxy.lan", http.StatusOK},
+ {"missing token", http.MethodPost, "", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
+ {"wrong token", http.MethodPost, "_csrf=bogus", "https://galaxy.lan", "galaxy.lan", http.StatusForbidden},
+ {"cross-origin", http.MethodPost, "_csrf=" + token, "https://evil.example", "galaxy.lan", http.StatusForbidden},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ var body io.Reader
+ if tc.form != "" {
+ body = strings.NewReader(tc.form)
+ }
+ req := httptest.NewRequest(tc.method, "/x", body)
+ if tc.form != "" {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ if tc.origin != "" {
+ req.Header.Set("Origin", tc.origin)
+ }
+ req.Host = tc.host
+
+ rec := httptest.NewRecorder()
+ engine.ServeHTTP(rec, req)
+
+ if rec.Code != tc.want {
+ t.Fatalf("status = %d, want %d (body=%s)", rec.Code, tc.want, rec.Body.String())
+ }
+ })
+ }
+}
diff --git a/backend/internal/server/handlers_admin_console_users.go b/backend/internal/server/handlers_admin_console_users.go
new file mode 100644
index 0000000..2ddf065
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_users.go
@@ -0,0 +1,252 @@
+package server
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "time"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/server/middleware/basicauth"
+ "galaxy/backend/internal/user"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+// UserAdmin is the subset of the user service the operator console depends on.
+// *user.Service satisfies it; tests supply a fake so the console pages render
+// without a database.
+type UserAdmin interface {
+ ListAccounts(ctx context.Context, page, pageSize int) (user.AccountPage, error)
+ GetAccount(ctx context.Context, userID uuid.UUID) (user.Account, error)
+ ApplySanction(ctx context.Context, input user.ApplySanctionInput) (user.Account, error)
+ ApplyEntitlement(ctx context.Context, input user.ApplyEntitlementInput) (user.Account, error)
+ SoftDelete(ctx context.Context, userID uuid.UUID, actor user.ActorRef) error
+}
+
+// consoleTiers lists the selectable entitlement tiers in display order.
+var consoleTiers = []string{user.TierFree, user.TierMonthly, user.TierYearly, user.TierPermanent}
+
+// UsersList renders GET /_gm/users — the paginated account list.
+func (h *AdminConsoleHandlers) UsersList() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.users == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
+ return
+ }
+ page := parsePositiveQueryInt(c.Query("page"), 1)
+ pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
+
+ result, err := h.users.ListAccounts(c.Request.Context(), page, pageSize)
+ if err != nil {
+ h.logger.Error("admin console: list users", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load users.", "bad", "/_gm/")
+ return
+ }
+ h.render(c, http.StatusOK, "users", "users", "Users", toUsersListData(result))
+ }
+}
+
+// UserDetail renders GET /_gm/users/:user_id.
+func (h *AdminConsoleHandlers) UserDetail() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.users == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
+ return
+ }
+ userID, ok := parseUserIDParam(c)
+ if !ok {
+ return
+ }
+ account, err := h.users.GetAccount(c.Request.Context(), userID)
+ if err != nil {
+ if errors.Is(err, user.ErrAccountNotFound) {
+ h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user, or the account has been soft-deleted.", "bad", "/_gm/users")
+ return
+ }
+ h.logger.Error("admin console: get user", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "users", "Users", "Failed to load the user.", "bad", "/_gm/users")
+ return
+ }
+ h.render(c, http.StatusOK, "user_detail", "users", account.Email, toUserDetailData(account))
+ }
+}
+
+// UserBlock handles POST /_gm/users/:user_id/block — applies a permanent block.
+func (h *AdminConsoleHandlers) UserBlock() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.users == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
+ return
+ }
+ userID, ok := parseUserIDParam(c)
+ if !ok {
+ return
+ }
+ back := "/_gm/users/" + userID.String()
+ reason := strings.TrimSpace(c.PostForm("reason_code"))
+ if reason == "" {
+ h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "A reason is required to block a user.", "bad", back)
+ return
+ }
+ _, err := h.users.ApplySanction(c.Request.Context(), user.ApplySanctionInput{
+ UserID: userID,
+ SanctionCode: user.SanctionCodePermanentBlock,
+ Scope: "account",
+ ReasonCode: reason,
+ Actor: actorFromContext(c),
+ })
+ if err != nil {
+ h.logger.Error("admin console: block user", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "users", "Block failed", "Failed to block the user.", "bad", back)
+ return
+ }
+ c.Redirect(http.StatusSeeOther, back)
+ }
+}
+
+// UserEntitlement handles POST /_gm/users/:user_id/entitlement.
+func (h *AdminConsoleHandlers) UserEntitlement() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.users == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
+ return
+ }
+ userID, ok := parseUserIDParam(c)
+ if !ok {
+ return
+ }
+ back := "/_gm/users/" + userID.String()
+ tier := strings.TrimSpace(c.PostForm("tier"))
+ source := strings.TrimSpace(c.PostForm("source"))
+ if source == "" {
+ source = "admin"
+ }
+ _, err := h.users.ApplyEntitlement(c.Request.Context(), user.ApplyEntitlementInput{
+ UserID: userID,
+ Tier: tier,
+ Source: source,
+ Actor: actorFromContext(c),
+ ReasonCode: strings.TrimSpace(c.PostForm("reason_code")),
+ })
+ if err != nil {
+ if errors.Is(err, user.ErrInvalidInput) {
+ h.renderMessage(c, http.StatusBadRequest, "users", "Invalid input", "The entitlement request was rejected: check the tier.", "bad", back)
+ return
+ }
+ h.logger.Error("admin console: apply entitlement", zap.Error(err))
+ h.renderMessage(c, http.StatusInternalServerError, "users", "Entitlement failed", "Failed to update the entitlement.", "bad", back)
+ return
+ }
+ c.Redirect(http.StatusSeeOther, back)
+ }
+}
+
+// UserSoftDelete handles POST /_gm/users/:user_id/soft-delete.
+func (h *AdminConsoleHandlers) UserSoftDelete() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ if h.users == nil {
+ h.renderMessage(c, http.StatusServiceUnavailable, "users", "Users", "User administration is not available.", "bad", "/_gm/")
+ return
+ }
+ userID, ok := parseUserIDParam(c)
+ if !ok {
+ return
+ }
+ if err := h.users.SoftDelete(c.Request.Context(), userID, actorFromContext(c)); err != nil {
+ if errors.Is(err, user.ErrAccountNotFound) {
+ h.renderMessage(c, http.StatusNotFound, "users", "User not found", "No such user.", "bad", "/_gm/users")
+ return
+ }
+ // A cascade error does not undo the soft delete; log and proceed.
+ h.logger.Warn("admin console: soft-delete cascade returned error", zap.Error(err))
+ }
+ c.Redirect(http.StatusSeeOther, "/_gm/users")
+ }
+}
+
+// actorFromContext builds the admin ActorRef for audit trails from the
+// authenticated operator username stored by the Basic Auth middleware.
+func actorFromContext(c *gin.Context) user.ActorRef {
+ username, _ := basicauth.UsernameFromContext(c.Request.Context())
+ return user.ActorRef{Type: "admin", ID: username}
+}
+
+// toUsersListData maps an account page into the users list view model.
+func toUsersListData(page user.AccountPage) adminconsole.UsersListData {
+ data := adminconsole.UsersListData{
+ Items: make([]adminconsole.UserRow, 0, len(page.Items)),
+ Page: page.Page,
+ PageSize: page.PageSize,
+ Total: page.Total,
+ PrevPage: page.Page - 1,
+ NextPage: page.Page + 1,
+ HasPrev: page.Page > 1,
+ HasNext: page.Page*page.PageSize < page.Total,
+ }
+ for _, account := range page.Items {
+ data.Items = append(data.Items, adminconsole.UserRow{
+ UserID: account.UserID.String(),
+ Email: account.Email,
+ UserName: account.UserName,
+ DisplayName: account.DisplayName,
+ Tier: account.Entitlement.Tier,
+ Blocked: account.PermanentBlock,
+ Deleted: account.DeletedAt != nil,
+ CreatedAt: fmtConsoleTime(account.CreatedAt),
+ })
+ }
+ return data
+}
+
+// toUserDetailData maps an account aggregate into the detail view model.
+func toUserDetailData(account user.Account) adminconsole.UserDetailData {
+ data := adminconsole.UserDetailData{
+ UserID: account.UserID.String(),
+ Email: account.Email,
+ UserName: account.UserName,
+ DisplayName: account.DisplayName,
+ PreferredLanguage: account.PreferredLanguage,
+ TimeZone: account.TimeZone,
+ DeclaredCountry: account.DeclaredCountry,
+ Blocked: account.PermanentBlock,
+ Deleted: account.DeletedAt != nil,
+ CreatedAt: fmtConsoleTime(account.CreatedAt),
+ UpdatedAt: fmtConsoleTime(account.UpdatedAt),
+ Tier: account.Entitlement.Tier,
+ IsPaid: account.Entitlement.IsPaid,
+ EntitlementSource: account.Entitlement.Source,
+ EntitlementReason: account.Entitlement.ReasonCode,
+ EntitlementEnds: fmtConsoleTimePtr(account.Entitlement.EndsAt),
+ Tiers: consoleTiers,
+ }
+ for _, sanction := range account.ActiveSanctions {
+ data.Sanctions = append(data.Sanctions, adminconsole.SanctionView{
+ SanctionCode: sanction.SanctionCode,
+ Scope: sanction.Scope,
+ ReasonCode: sanction.ReasonCode,
+ AppliedAt: fmtConsoleTime(sanction.AppliedAt),
+ ExpiresAt: fmtConsoleTimePtr(sanction.ExpiresAt),
+ })
+ }
+ return data
+}
+
+// fmtConsoleTime renders a timestamp for display in the console.
+func fmtConsoleTime(t time.Time) string {
+ if t.IsZero() {
+ return ""
+ }
+ return t.UTC().Format("2006-01-02 15:04 UTC")
+}
+
+// fmtConsoleTimePtr renders an optional timestamp, returning "" when nil.
+func fmtConsoleTimePtr(t *time.Time) string {
+ if t == nil {
+ return ""
+ }
+ return fmtConsoleTime(*t)
+}
diff --git a/backend/internal/server/handlers_admin_console_users_test.go b/backend/internal/server/handlers_admin_console_users_test.go
new file mode 100644
index 0000000..ffe5b74
--- /dev/null
+++ b/backend/internal/server/handlers_admin_console_users_test.go
@@ -0,0 +1,288 @@
+package server
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "galaxy/backend/internal/adminconsole"
+ "galaxy/backend/internal/server/middleware/basicauth"
+ "galaxy/backend/internal/user"
+
+ "github.com/google/uuid"
+ "go.uber.org/zap"
+)
+
+// fakeUserAdmin records calls so the console handlers can be exercised without
+// a database.
+type fakeUserAdmin struct {
+ page user.AccountPage
+ account user.Account
+ getErr error
+
+ sanctionCalls int
+ lastSanction user.ApplySanctionInput
+ entitlementCall int
+ lastEntitlement user.ApplyEntitlementInput
+ softDeleteCalls int
+ lastSoftActor user.ActorRef
+}
+
+func (f *fakeUserAdmin) ListAccounts(context.Context, int, int) (user.AccountPage, error) {
+ return f.page, nil
+}
+
+func (f *fakeUserAdmin) GetAccount(context.Context, uuid.UUID) (user.Account, error) {
+ return f.account, f.getErr
+}
+
+func (f *fakeUserAdmin) ApplySanction(_ context.Context, in user.ApplySanctionInput) (user.Account, error) {
+ f.sanctionCalls++
+ f.lastSanction = in
+ return f.account, nil
+}
+
+func (f *fakeUserAdmin) ApplyEntitlement(_ context.Context, in user.ApplyEntitlementInput) (user.Account, error) {
+ f.entitlementCall++
+ f.lastEntitlement = in
+ return f.account, nil
+}
+
+func (f *fakeUserAdmin) SoftDelete(_ context.Context, _ uuid.UUID, actor user.ActorRef) error {
+ f.softDeleteCalls++
+ f.lastSoftActor = actor
+ return nil
+}
+
+func newUsersConsoleRouter(t *testing.T, users UserAdmin) (http.Handler, *adminconsole.CSRF) {
+ t.Helper()
+ csrf := adminconsole.NewCSRF([]byte("test-key"))
+ handler, err := NewRouter(RouterDependencies{
+ Logger: zap.NewNop(),
+ AdminVerifier: basicauth.NewStaticVerifier("secret"),
+ AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Users: users}),
+ })
+ if err != nil {
+ t.Fatalf("NewRouter: %v", err)
+ }
+ return handler, csrf
+}
+
+func TestConsoleUsersList(t *testing.T) {
+ fake := &fakeUserAdmin{page: user.AccountPage{
+ Items: []user.Account{
+ {UserID: uuid.New(), Email: "alice@example.test", UserName: "alice"},
+ {UserID: uuid.New(), Email: "bob@example.test", UserName: "bob", PermanentBlock: true},
+ },
+ Page: 1, PageSize: 50, Total: 2,
+ }}
+ router, _ := newUsersConsoleRouter(t, fake)
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{"alice@example.test", "bob@example.test", "blocked", "page 1"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("users list missing %q", want)
+ }
+ }
+}
+
+func TestConsoleUserDetailRendersForms(t *testing.T) {
+ id := uuid.New()
+ fake := &fakeUserAdmin{account: user.Account{
+ UserID: id, Email: "alice@example.test", UserName: "alice",
+ Entitlement: user.EntitlementSnapshot{Tier: user.TierFree},
+ }}
+ router, csrf := newUsersConsoleRouter(t, fake)
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+id.String(), nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ for _, want := range []string{
+ "alice@example.test",
+ "Permanently block",
+ "Update entitlement",
+ "Soft-delete account",
+ csrf.Token("ops"),
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("user detail missing %q", want)
+ }
+ }
+}
+
+func TestConsoleUserDetailNotFound(t *testing.T) {
+ fake := &fakeUserAdmin{getErr: user.ErrAccountNotFound}
+ router, _ := newUsersConsoleRouter(t, fake)
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/users/"+uuid.New().String(), nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "not found") {
+ t.Error("expected a not-found message")
+ }
+}
+
+func TestConsoleUserBlock(t *testing.T) {
+ id := uuid.New()
+ fake := &fakeUserAdmin{account: user.Account{UserID: id}}
+ router, csrf := newUsersConsoleRouter(t, fake)
+
+ form := "_csrf=" + csrf.Token("ops") + "&reason_code=spam"
+ req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://galaxy.lan")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
+ }
+ if fake.sanctionCalls != 1 {
+ t.Fatalf("ApplySanction called %d times, want 1", fake.sanctionCalls)
+ }
+ if fake.lastSanction.SanctionCode != user.SanctionCodePermanentBlock {
+ t.Errorf("sanction code = %q, want permanent_block", fake.lastSanction.SanctionCode)
+ }
+ if fake.lastSanction.Scope != "account" {
+ t.Errorf("scope = %q, want account", fake.lastSanction.Scope)
+ }
+ if fake.lastSanction.ReasonCode != "spam" {
+ t.Errorf("reason = %q, want spam", fake.lastSanction.ReasonCode)
+ }
+ if fake.lastSanction.Actor.Type != "admin" || fake.lastSanction.Actor.ID != "ops" {
+ t.Errorf("actor = %+v, want admin/ops", fake.lastSanction.Actor)
+ }
+ if fake.lastSanction.UserID != id {
+ t.Errorf("sanction user id = %s, want %s", fake.lastSanction.UserID, id)
+ }
+}
+
+func TestConsoleUserBlockMissingReason(t *testing.T) {
+ id := uuid.New()
+ fake := &fakeUserAdmin{account: user.Account{UserID: id}}
+ router, csrf := newUsersConsoleRouter(t, fake)
+
+ form := "_csrf=" + csrf.Token("ops")
+ req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader(form))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://galaxy.lan")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rec.Code)
+ }
+ if fake.sanctionCalls != 0 {
+ t.Errorf("ApplySanction must not be called without a reason")
+ }
+}
+
+func TestConsoleUserBlockRejectsBadCSRF(t *testing.T) {
+ id := uuid.New()
+ fake := &fakeUserAdmin{account: user.Account{UserID: id}}
+ router, _ := newUsersConsoleRouter(t, fake)
+
+ req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/block", strings.NewReader("reason_code=spam"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://galaxy.lan")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rec.Code)
+ }
+ if fake.sanctionCalls != 0 {
+ t.Errorf("ApplySanction must not run when the CSRF token is missing")
+ }
+}
+
+func TestConsoleUserEntitlement(t *testing.T) {
+ id := uuid.New()
+ fake := &fakeUserAdmin{account: user.Account{UserID: id}}
+ router, csrf := newUsersConsoleRouter(t, fake)
+
+ form := "_csrf=" + csrf.Token("ops") + "&tier=monthly&source=admin&reason_code=promo"
+ req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/entitlement", strings.NewReader(form))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://galaxy.lan")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303; body=%s", rec.Code, rec.Body.String())
+ }
+ if fake.entitlementCall != 1 {
+ t.Fatalf("ApplyEntitlement called %d times, want 1", fake.entitlementCall)
+ }
+ if fake.lastEntitlement.Tier != user.TierMonthly {
+ t.Errorf("tier = %q, want monthly", fake.lastEntitlement.Tier)
+ }
+ if fake.lastEntitlement.Actor.ID != "ops" {
+ t.Errorf("actor id = %q, want ops", fake.lastEntitlement.Actor.ID)
+ }
+}
+
+func TestConsoleUserSoftDelete(t *testing.T) {
+ id := uuid.New()
+ fake := &fakeUserAdmin{account: user.Account{UserID: id}}
+ router, csrf := newUsersConsoleRouter(t, fake)
+
+ form := "_csrf=" + csrf.Token("ops")
+ req := httptest.NewRequest(http.MethodPost, "http://galaxy.lan/_gm/users/"+id.String()+"/soft-delete", strings.NewReader(form))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://galaxy.lan")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want 303", rec.Code)
+ }
+ if got := rec.Header().Get("Location"); got != "/_gm/users" {
+ t.Errorf("redirect Location = %q, want /_gm/users", got)
+ }
+ if fake.softDeleteCalls != 1 {
+ t.Fatalf("SoftDelete called %d times, want 1", fake.softDeleteCalls)
+ }
+ if fake.lastSoftActor.ID != "ops" {
+ t.Errorf("soft-delete actor = %q, want ops", fake.lastSoftActor.ID)
+ }
+}
+
+func TestConsoleUsersUnavailable(t *testing.T) {
+ router, _ := newUsersConsoleRouter(t, nil) // no user service wired
+
+ req := httptest.NewRequest(http.MethodGet, "/_gm/users", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", rec.Code)
+ }
+}
diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go
index d26b339..5a44de0 100644
--- a/backend/internal/server/router.go
+++ b/backend/internal/server/router.go
@@ -81,6 +81,13 @@ type RouterDependencies struct {
AdminGeo *AdminGeoHandlers
InternalSessions *InternalSessionsHandlers
InternalUsers *InternalUsersHandlers
+
+ // AdminConsole, when non-nil, mounts the server-rendered operator
+ // console under the `/_gm` route group behind the same admin Basic
+ // Auth verifier as `/api/v1/admin`. A nil value leaves the console
+ // unmounted, which keeps routers built without console wiring (the
+ // contract test, most unit tests) unchanged.
+ AdminConsole *AdminConsoleHandlers
}
// NewRouter constructs the backend gin engine wired with the documented
@@ -123,6 +130,7 @@ func NewRouter(deps RouterDependencies) (http.Handler, error) {
registerUserRoutes(router, instruments, deps)
registerAdminRoutes(router, instruments, deps)
registerInternalRoutes(router, instruments, deps)
+ registerAdminConsoleRoutes(router, deps)
router.NoMethod(func(c *gin.Context) {
if allow := allowedMethodsForPath(c.Request.URL.Path); allow != "" {
@@ -364,6 +372,57 @@ func registerInternalRoutes(router *gin.Engine, instruments *metrics.Instruments
users.GET("/:user_id/account-internal", deps.InternalUsers.GetAccountInternal())
}
+// registerAdminConsoleRoutes mounts the server-rendered operator console under
+// `/_gm` when deps.AdminConsole is wired. The group reuses the same admin Basic
+// Auth verifier as `/api/v1/admin`; the CSRF guard then protects every
+// state-changing request. A nil AdminConsole leaves the surface unmounted.
+func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
+ if deps.AdminConsole == nil {
+ return
+ }
+
+ group := router.Group("/_gm")
+ group.Use(basicauth.Middleware(deps.AdminVerifier, adminBasicAuthRealm))
+ group.Use(deps.AdminConsole.RequireCSRF())
+
+ group.GET("/assets/*filepath", deps.AdminConsole.Asset())
+ group.GET("", deps.AdminConsole.Dashboard())
+ group.GET("/", deps.AdminConsole.Dashboard())
+
+ group.GET("/users", deps.AdminConsole.UsersList())
+ group.GET("/users/:user_id", deps.AdminConsole.UserDetail())
+ group.POST("/users/:user_id/block", deps.AdminConsole.UserBlock())
+ group.POST("/users/:user_id/entitlement", deps.AdminConsole.UserEntitlement())
+ group.POST("/users/:user_id/soft-delete", deps.AdminConsole.UserSoftDelete())
+
+ group.GET("/games", deps.AdminConsole.GamesList())
+ group.POST("/games", deps.AdminConsole.GameCreate())
+ group.GET("/games/:game_id", deps.AdminConsole.GameDetail())
+ group.POST("/games/:game_id/force-start", deps.AdminConsole.GameForceStart())
+ group.POST("/games/:game_id/force-stop", deps.AdminConsole.GameForceStop())
+ group.POST("/games/:game_id/ban-member", deps.AdminConsole.GameBanMember())
+ group.POST("/games/:game_id/runtime/restart", deps.AdminConsole.RuntimeRestart())
+ group.POST("/games/:game_id/runtime/patch", deps.AdminConsole.RuntimePatch())
+ group.POST("/games/:game_id/runtime/force-next-turn", deps.AdminConsole.RuntimeForceNextTurn())
+
+ group.GET("/engine-versions", deps.AdminConsole.EngineVersionsList())
+ group.POST("/engine-versions", deps.AdminConsole.EngineVersionRegister())
+ group.POST("/engine-versions/:version/disable", deps.AdminConsole.EngineVersionDisable())
+
+ group.GET("/operators", deps.AdminConsole.OperatorsList())
+ group.POST("/operators", deps.AdminConsole.OperatorCreate())
+ group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
+ group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
+ group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
+
+ group.GET("/mail", deps.AdminConsole.MailPage())
+ group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail())
+ group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend())
+ group.GET("/notifications", deps.AdminConsole.NotificationsPage())
+ group.GET("/broadcast", deps.AdminConsole.BroadcastForm())
+ group.POST("/broadcast", deps.AdminConsole.BroadcastSend())
+}
+
// allowedMethodsForPath returns the comma-separated list of methods
// the gin router accepts on requestPath. Only the probe paths declare
// a non-empty list so NoMethod can advertise a useful `Allow` header
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3a7f321..a771037 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -581,6 +581,36 @@ directly.
`/api/v1/admin/notifications/*`) reuse the per-domain logic of the
module they target.
+### 14.1 Operator console (`/_gm`)
+
+`backend` also serves a server-rendered operator console under the `/_gm`
+route group — the human-facing surface for the admin operations otherwise
+exposed as JSON under `/api/v1/admin/*`. It reuses the `admin_accounts`
+Basic Auth verifier and renders pages with the standard library's
+`html/template` (navigation by path and query, Post/Redirect/Get on
+writes; no client framework or build step).
+
+Unlike the internal-only JSON admin API, the console is reachable from the
+public edge: Caddy routes `/_gm/*` to the gateway public listener, which
+classifies it as the `admin` anti-abuse class (per-IP rate limit, body and
+method limits) and reverse-proxies it to `backend`'s `/_gm` surface. The
+gateway preserves the inbound `Host` and relays the backend's 401 Basic
+Auth challenge unchanged, so the browser shows its native credential
+dialog. Authentication is enforced by `backend`; the gateway contributes
+only the edge anti-abuse layer.
+
+State-changing requests are guarded against CSRF by a stateless token
+(HMAC-SHA256 over the authenticated username, keyed by
+`BACKEND_ADMIN_CONSOLE_CSRF_KEY`; a per-process random key is used when the
+variable is unset) plus a same-origin `Origin`/`Referer` check.
+
+The console landing page is a dashboard that surfaces backend-visible
+operational signals — database reachability, per-status game-runtime counts,
+and mail/notification queue depths — read directly through the persistence
+layer; richer historical metrics come from the Prometheus exporters on
+`backend` and `gateway` (see [§17](#17-observability)). See
+`backend/docs/admin-console.md` for the console design.
+
## 15. Transport Security Model (gateway boundary)
This section describes the secure exchange model between client and
@@ -823,7 +853,8 @@ business validation and authorisation.
| Session revocation propagation | backend → gateway | `session_invalidation` over the gRPC push stream flips the gateway-side cache entry to revoked and closes any active push stream. |
| Authorisation, ownership, state transitions | backend | `X-User-ID` is the sole identity input on the user surface. |
| Edge rate limiting | gateway | Backend has no rate-limit responsibility in MVP. |
-| Admin authentication | backend | Basic Auth against `admin_accounts`. |
+| Admin authentication | backend | Basic Auth against `admin_accounts`; the `/_gm` operator console reuses the same verifier. |
+| Admin console CSRF | backend | Stateless HMAC token (`BACKEND_ADMIN_CONSOLE_CSRF_KEY`) + same-origin `Origin`/`Referer` check on `/_gm` writes. |
| Engine API authentication | network | Engine listens only on the trusted network; backend is the only caller. |
### Backend ↔ Gateway trust
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 6eace12..d9b22cf 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -1162,6 +1162,28 @@ operator's password manager can match it across deployments.
After the first deployment, the bootstrap password should be
rotated through the admin surface.
+### 10.2.1 Operator console (`/_gm`)
+
+Administrators drive these operations either programmatically through
+the JSON admin API or through a server-rendered web console at `/_gm`.
+The console authenticates with the same Basic Auth credentials: opening
+any `/_gm` page prompts the browser's native credential dialog, and the
+operator stays signed in for the session. Navigation is by ordinary
+links and query parameters; every change is submitted as a form and
+answered with a redirect back to the affected page.
+
+The console is the only admin surface reachable from outside the trusted
+network. It is fronted by the gateway, so it inherits the same edge rate
+limiting and request limits as the public API, and it carries an
+anti-CSRF token on every change. The JSON admin API stays internal to
+the deployment.
+
+The console landing page is a dashboard that summarises operational
+health: whether the backend is ready and the database reachable, how many
+game runtimes sit in each state, and the depth of the mail and
+notification queues. It is a read-only point-in-time view for quick
+triage, not a metrics history.
+
### 10.3 Admin account management
Existing admins can list other admins, create new ones, look up a
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index 756d1e4..a81f6d7 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -1197,6 +1197,29 @@ deployments.
После первого деплоя bootstrap-пароль должен быть ротирован
через admin-surface.
+### 10.2.1 Операторская консоль (`/_gm`)
+
+Администраторы выполняют эти операции либо программно через JSON
+admin-API, либо через серверно-рендеримую веб-консоль на `/_gm`.
+Консоль аутентифицируется теми же Basic Auth-учётными данными:
+открытие любой страницы `/_gm` вызывает нативный диалог браузера для
+ввода учётных данных, и оператор остаётся залогинен на время сессии.
+Навигация — обычными ссылками и query-параметрами; каждое изменение
+отправляется формой и завершается редиректом обратно на затронутую
+страницу.
+
+Консоль — единственная admin-поверхность, достижимая извне
+доверенной сети. Она проксируется через gateway, поэтому наследует те
+же edge-rate-limiting и лимиты запросов, что и публичный API, и несёт
+анти-CSRF-токен на каждом изменении. JSON admin-API остаётся
+внутренним для деплоя.
+
+Стартовая страница консоли — дашборд, сводящий операционное
+здоровье: готов ли backend и доступна ли БД, сколько игровых рантаймов
+в каждом состоянии, какова глубина очередей почты и уведомлений. Это
+read-only-срез на текущий момент для быстрой диагностики, не история
+метрик.
+
### 10.3 Управление admin-аккаунтами
Существующие админы могут перечислять других админов, создавать
diff --git a/gateway/README.md b/gateway/README.md
index 28fb905..64f4c61 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -178,6 +178,30 @@ bootstrap or asset traffic through a pluggable public handler or proxy.
That traffic belongs to dedicated public route classes and must not share rate
limit buckets or abuse counters with the public auth API.
+### Operator Console Proxy (`/_gm`)
+
+The gateway also fronts the backend operator console. The edge Caddy routes
+`/_gm` and `/_gm/*` to this public listener; the gateway classifies that
+traffic as the `admin` public route class and reverse-proxies it to the
+backend at `GATEWAY_BACKEND_HTTP_URL`, preserving the request path and the
+inbound `Host` header (so the backend's same-origin CSRF check observes the
+public host).
+
+Authentication is delegated entirely to the backend (HTTP Basic Auth against
+`admin_accounts`): the backend's `401` challenge is relayed unchanged so the
+browser shows its native credential dialog. The gateway contributes only the
+edge anti-abuse layer — a per-IP rate limit, a body size limit, and a
+`GET`/`HEAD`/`POST` method allow-list for the class — and answers
+`502 bad_gateway` when the backend is unreachable.
+
+The `admin` class carries its own budgets, isolated from the other public
+classes:
+
+- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES` (default `65536`);
+- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS` (default `120`);
+- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_WINDOW` (default `1m`);
+- `GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST` (default `40`).
+
### Operational Admin Surface
The gateway may expose one private operational HTTP listener used for metrics.
diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go
index 2c1095b..3c30405 100644
--- a/gateway/cmd/gateway/main.go
+++ b/gateway/cmd/gateway/main.go
@@ -78,6 +78,15 @@ func run(ctx context.Context) (err error) {
AuthService: authServiceAdapter{rest: backend.REST()},
}
+ adminConsoleProxy, err := restapi.NewBackendConsoleProxy(cfg.Backend.HTTPBaseURL, logger)
+ if err != nil {
+ _ = backend.Close()
+ _ = telemetryRuntime.Shutdown(context.Background())
+ _ = logging.Sync(logger)
+ return fmt.Errorf("build admin console proxy: %w", err)
+ }
+ publicRESTDeps.AdminConsoleProxy = adminConsoleProxy
+
grpcDeps, components, cleanup, err := newAuthenticatedGRPCDependencies(ctx, cfg, logger, telemetryRuntime, backend)
if err != nil {
_ = backend.Close()
diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go
index 3d697f6..410acb4 100644
--- a/gateway/internal/config/config.go
+++ b/gateway/internal/config/config.go
@@ -276,6 +276,22 @@ const (
// configures the public_misc rate-limit burst.
publicMiscRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST"
+ // adminMaxBodyBytesEnvVar names the environment variable that configures
+ // the maximum accepted request body size for the admin console class.
+ adminMaxBodyBytesEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES"
+
+ // adminRateLimitRequestsEnvVar names the environment variable that
+ // configures the admin console request budget per window.
+ adminRateLimitRequestsEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS"
+
+ // adminRateLimitWindowEnvVar names the environment variable that configures
+ // the admin console rate-limit window.
+ adminRateLimitWindowEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_WINDOW"
+
+ // adminRateLimitBurstEnvVar names the environment variable that configures
+ // the admin console rate-limit burst.
+ adminRateLimitBurstEnvVar = "GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST"
+
// sendEmailCodeIdentityRateLimitRequestsEnvVar names the environment
// variable that configures the send-email-code identity request budget per
// window.
@@ -372,6 +388,14 @@ const (
defaultPublicMiscRateLimitRequests = 30
defaultPublicMiscRateLimitBurst = 10
+ // Admin console class: sized for a human operator clicking through pages
+ // and submitting forms, while still throttling Basic Auth brute-force at
+ // the edge. The body budget accommodates form posts.
+ defaultAdminMaxBodyBytes = int64(65536)
+
+ defaultAdminRateLimitRequests = 120
+ defaultAdminRateLimitBurst = 40
+
defaultSendEmailCodeIdentityRateLimitRequests = 3
defaultSendEmailCodeIdentityRateLimitBurst = 1
@@ -439,6 +463,11 @@ type PublicHTTPAntiAbuseConfig struct {
// PublicMisc applies to the stable public_misc route class.
PublicMisc PublicRoutePolicyConfig
+ // Admin applies to the stable admin route class — the `/_gm` operator
+ // console reverse-proxied to the backend. Only per-IP limiting applies;
+ // the class carries no identity buckets.
+ Admin PublicRoutePolicyConfig
+
// SendEmailCodeIdentity applies the additional identity limiter for
// send-email-code.
SendEmailCodeIdentity PublicAuthIdentityPolicyConfig
@@ -708,6 +737,14 @@ func DefaultPublicHTTPConfig() PublicHTTPConfig {
Burst: defaultPublicMiscRateLimitBurst,
},
},
+ Admin: PublicRoutePolicyConfig{
+ MaxBodyBytes: defaultAdminMaxBodyBytes,
+ RateLimit: PublicRateLimitConfig{
+ Requests: defaultAdminRateLimitRequests,
+ Window: defaultClassRateLimitWindow,
+ Burst: defaultAdminRateLimitBurst,
+ },
+ },
SendEmailCodeIdentity: PublicAuthIdentityPolicyConfig{
RateLimit: PublicRateLimitConfig{
Requests: defaultSendEmailCodeIdentityRateLimitRequests,
@@ -1092,6 +1129,18 @@ func LoadFromEnv() (Config, error) {
}
cfg.PublicHTTP.AntiAbuse.PublicMisc = publicMiscPolicy
+ adminPolicy, err := loadPublicRoutePolicyConfigFromEnv(
+ cfg.PublicHTTP.AntiAbuse.Admin,
+ adminMaxBodyBytesEnvVar,
+ adminRateLimitRequestsEnvVar,
+ adminRateLimitWindowEnvVar,
+ adminRateLimitBurstEnvVar,
+ )
+ if err != nil {
+ return Config{}, err
+ }
+ cfg.PublicHTTP.AntiAbuse.Admin = adminPolicy
+
sendIdentityPolicy, err := loadPublicAuthIdentityPolicyConfigFromEnv(
cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity,
sendEmailCodeIdentityRateLimitRequestsEnvVar,
@@ -1247,6 +1296,9 @@ func LoadFromEnv() (Config, error) {
if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.PublicMisc, publicMiscMaxBodyBytesEnvVar, publicMiscRateLimitRequestsEnvVar, publicMiscRateLimitWindowEnvVar, publicMiscRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
+ if err := validatePublicRoutePolicyConfig(cfg.PublicHTTP.AntiAbuse.Admin, adminMaxBodyBytesEnvVar, adminRateLimitRequestsEnvVar, adminRateLimitWindowEnvVar, adminRateLimitBurstEnvVar); err != nil {
+ return Config{}, err
+ }
if err := validatePublicAuthIdentityPolicyConfig(cfg.PublicHTTP.AntiAbuse.SendEmailCodeIdentity, sendEmailCodeIdentityRateLimitRequestsEnvVar, sendEmailCodeIdentityRateLimitWindowEnvVar, sendEmailCodeIdentityRateLimitBurstEnvVar); err != nil {
return Config{}, err
}
diff --git a/gateway/internal/restapi/admin_proxy.go b/gateway/internal/restapi/admin_proxy.go
new file mode 100644
index 0000000..d9f2855
--- /dev/null
+++ b/gateway/internal/restapi/admin_proxy.go
@@ -0,0 +1,49 @@
+package restapi
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+
+ "go.uber.org/zap"
+)
+
+// NewBackendConsoleProxy builds the reverse proxy that forwards operator
+// console traffic (`/_gm` and `/_gm/*`) to the backend at backendBaseURL.
+//
+// The proxy is intentionally thin: it preserves the inbound request path and
+// the inbound Host header — the latter so the backend's same-origin CSRF check
+// observes the public host rather than the internal upstream — and relays the
+// backend response unchanged, including its 401 Basic Auth challenge. It
+// answers 502 when the backend is unreachable. Authentication, rendering, and
+// every state change live in the backend; the gateway contributes only the
+// public anti-abuse layer that runs ahead of this handler.
+func NewBackendConsoleProxy(backendBaseURL string, logger *zap.Logger) (http.Handler, error) {
+ target, err := url.Parse(backendBaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("parse backend base URL %q: %w", backendBaseURL, err)
+ }
+ if target.Scheme == "" || target.Host == "" {
+ return nil, fmt.Errorf("backend base URL %q must be absolute", backendBaseURL)
+ }
+
+ if logger == nil {
+ logger = zap.NewNop()
+ }
+ logger = logger.Named("admin_console_proxy")
+
+ return &httputil.ReverseProxy{
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ pr.SetURL(target)
+ // SetURL clears Out.Host so the target host is used; restore the
+ // inbound Host so the backend sees the public origin.
+ pr.Out.Host = pr.In.Host
+ },
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
+ logger.Warn("admin console upstream error",
+ zap.String("path", r.URL.Path), zap.Error(err))
+ w.WriteHeader(http.StatusBadGateway)
+ },
+ }, nil
+}
diff --git a/gateway/internal/restapi/admin_proxy_test.go b/gateway/internal/restapi/admin_proxy_test.go
new file mode 100644
index 0000000..d2c26ed
--- /dev/null
+++ b/gateway/internal/restapi/admin_proxy_test.go
@@ -0,0 +1,198 @@
+package restapi
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "galaxy/gateway/internal/config"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// proxyRequest builds a test request whose context carries a cancellation
+// signal. A real http.Server always supplies one; httptest.NewRequest does not,
+// and without it httputil.ReverseProxy falls back to the legacy CloseNotifier
+// path, which panics under gin's ResponseWriter wrapping an
+// httptest.ResponseRecorder. Cancelling at test cleanup keeps the context live
+// for the synchronous ServeHTTP call.
+func proxyRequest(t *testing.T, method, target string, body io.Reader) *http.Request {
+ t.Helper()
+ req := httptest.NewRequest(method, target, body)
+ ctx, cancel := context.WithCancel(req.Context())
+ t.Cleanup(cancel)
+ return req.WithContext(ctx)
+}
+
+func TestAdminConsoleProxyForwardsToBackend(t *testing.T) {
+ var gotPath, gotHost, gotAuth string
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotHost = r.Host
+ gotAuth = r.Header.Get("Authorization")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte("Dashboard
"))
+ }))
+ defer backend.Close()
+
+ proxy, err := NewBackendConsoleProxy(backend.URL, nil)
+ require.NoError(t, err)
+ handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
+
+ req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusOK, rec.Code)
+ assert.Contains(t, rec.Body.String(), "Dashboard")
+ assert.Equal(t, "/_gm/", gotPath)
+ assert.Equal(t, "galaxy.lan", gotHost, "inbound Host must be preserved for same-origin CSRF checks")
+ assert.True(t, strings.HasPrefix(gotAuth, "Basic "), "Authorization header must be forwarded to the backend")
+}
+
+func TestAdminConsoleProxyForwardsFormPost(t *testing.T) {
+ var gotPath, gotBody, gotContentType string
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotContentType = r.Header.Get("Content-Type")
+ body, _ := io.ReadAll(r.Body)
+ gotBody = string(body)
+ w.WriteHeader(http.StatusSeeOther)
+ }))
+ defer backend.Close()
+
+ proxy, err := NewBackendConsoleProxy(backend.URL, nil)
+ require.NoError(t, err)
+ handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
+
+ const form = "_csrf=token&reason=spam"
+ req := proxyRequest(t, http.MethodPost, "http://galaxy.lan/_gm/users/1/sanctions", strings.NewReader(form))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth("ops", "secret")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusSeeOther, rec.Code)
+ assert.Equal(t, "/_gm/users/1/sanctions", gotPath)
+ assert.Equal(t, form, gotBody, "request body must reach the backend intact through the anti-abuse buffer")
+ assert.Contains(t, gotContentType, "x-www-form-urlencoded")
+}
+
+func TestAdminConsoleProxyRelaysAuthChallenge(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("WWW-Authenticate", `Basic realm="galaxy-admin"`)
+ w.WriteHeader(http.StatusUnauthorized)
+ }))
+ defer backend.Close()
+
+ proxy, err := NewBackendConsoleProxy(backend.URL, nil)
+ require.NoError(t, err)
+ handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
+
+ req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ require.Equal(t, http.StatusUnauthorized, rec.Code)
+ assert.Contains(t, rec.Header().Get("WWW-Authenticate"), "Basic")
+}
+
+func TestAdminConsoleProxyRejectsDisallowedMethod(t *testing.T) {
+ var hits int32
+ backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
+ atomic.AddInt32(&hits, 1)
+ }))
+ defer backend.Close()
+
+ proxy, err := NewBackendConsoleProxy(backend.URL, nil)
+ require.NoError(t, err)
+ handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
+
+ req := proxyRequest(t, http.MethodDelete, "http://galaxy.lan/_gm/users/1", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusMethodNotAllowed, rec.Code)
+ assert.Equal(t, int32(0), atomic.LoadInt32(&hits), "backend must not be reached for a rejected method")
+}
+
+func TestAdminConsoleProxyRejectsOversizedBody(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ defer backend.Close()
+
+ proxy, err := NewBackendConsoleProxy(backend.URL, nil)
+ require.NoError(t, err)
+ cfg := config.DefaultPublicHTTPConfig()
+ cfg.AntiAbuse.Admin.MaxBodyBytes = 8
+ handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AdminConsoleProxy: proxy})
+
+ req := proxyRequest(t, http.MethodPost, "http://galaxy.lan/_gm/users/1/sanctions",
+ strings.NewReader("this body is well beyond eight bytes"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
+}
+
+func TestAdminConsoleProxyRateLimitsPerIP(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer backend.Close()
+
+ proxy, err := NewBackendConsoleProxy(backend.URL, nil)
+ require.NoError(t, err)
+ cfg := config.DefaultPublicHTTPConfig()
+ cfg.AntiAbuse.Admin.RateLimit = config.PublicRateLimitConfig{Requests: 1, Window: time.Minute, Burst: 1}
+ handler := newPublicHandlerWithConfig(cfg, ServerDependencies{AdminConsoleProxy: proxy})
+
+ do := func() int {
+ req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
+ req.RemoteAddr = "203.0.113.7:5555"
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+ return rec.Code
+ }
+
+ assert.Equal(t, http.StatusOK, do(), "first request within budget")
+ assert.Equal(t, http.StatusTooManyRequests, do(), "second request exhausts the per-IP admin budget")
+}
+
+func TestAdminConsoleProxyReturns502WhenBackendUnreachable(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ backendURL := backend.URL
+ backend.Close() // close immediately so the next dial is refused
+
+ proxy, err := NewBackendConsoleProxy(backendURL, nil)
+ require.NoError(t, err)
+ handler := newPublicHandlerWithConfig(config.DefaultPublicHTTPConfig(), ServerDependencies{AdminConsoleProxy: proxy})
+
+ req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusBadGateway, rec.Code)
+}
+
+func TestAdminConsoleNotMountedWhenProxyNil(t *testing.T) {
+ handler := newPublicHandler(ServerDependencies{})
+
+ req := proxyRequest(t, http.MethodGet, "http://galaxy.lan/_gm/", nil)
+ rec := httptest.NewRecorder()
+ handler.ServeHTTP(rec, req)
+
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+}
+
+func TestNewBackendConsoleProxyRejectsRelativeURL(t *testing.T) {
+ _, err := NewBackendConsoleProxy("/not-absolute", nil)
+ assert.Error(t, err)
+}
diff --git a/gateway/internal/restapi/public_anti_abuse.go b/gateway/internal/restapi/public_anti_abuse.go
index c390fe6..ebbe742 100644
--- a/gateway/internal/restapi/public_anti_abuse.go
+++ b/gateway/internal/restapi/public_anti_abuse.go
@@ -234,6 +234,8 @@ func publicRoutePolicyForClass(policy config.PublicHTTPAntiAbuseConfig, class Pu
return policy.BrowserBootstrap
case PublicRouteClassBrowserAsset:
return policy.BrowserAsset
+ case PublicRouteClassAdmin:
+ return policy.Admin
default:
return policy.PublicMisc
}
@@ -252,6 +254,8 @@ func publicAuthIdentityPolicyForPath(requestPath string, policy config.PublicHTT
func allowedMethodsForRequestShape(r *http.Request) []string {
switch {
+ case isAdminConsolePath(r.URL.Path):
+ return []string{http.MethodGet, http.MethodHead, http.MethodPost}
case isPublicAuthPath(r.URL.Path):
return []string{http.MethodPost}
case isProbePath(r.URL.Path):
@@ -284,6 +288,17 @@ func isPublicAuthPath(requestPath string) bool {
}
}
+// isAdminConsoleRequest reports whether r targets the operator console surface.
+func isAdminConsoleRequest(r *http.Request) bool {
+ return isAdminConsolePath(r.URL.Path)
+}
+
+// isAdminConsolePath reports whether requestPath is the admin console root
+// (`/_gm`) or any path beneath it (`/_gm/...`).
+func isAdminConsolePath(requestPath string) bool {
+ return requestPath == "/_gm" || strings.HasPrefix(requestPath, "/_gm/")
+}
+
func isProbePath(requestPath string) bool {
switch requestPath {
case "/healthz", "/readyz":
diff --git a/gateway/internal/restapi/server.go b/gateway/internal/restapi/server.go
index 6a9c788..839407e 100644
--- a/gateway/internal/restapi/server.go
+++ b/gateway/internal/restapi/server.go
@@ -48,6 +48,10 @@ const (
// PublicRouteClassPublicMisc identifies public traffic that does not match a
// more specific class.
PublicRouteClassPublicMisc PublicRouteClass = "public_misc"
+
+ // PublicRouteClassAdmin identifies operator console traffic reverse-proxied
+ // to the backend under the `/_gm` prefix.
+ PublicRouteClassAdmin PublicRouteClass = "admin"
)
var configureGinModeOnce sync.Once
@@ -60,6 +64,7 @@ func (c PublicRouteClass) Normalized() PublicRouteClass {
case PublicRouteClassPublicAuth,
PublicRouteClassBrowserBootstrap,
PublicRouteClassBrowserAsset,
+ PublicRouteClassAdmin,
PublicRouteClassPublicMisc:
return c
default:
@@ -110,6 +115,14 @@ type ServerDependencies struct {
// Telemetry records low-cardinality edge metrics. When nil, metrics are
// disabled.
Telemetry *telemetry.Runtime
+
+ // AdminConsoleProxy, when non-nil, handles `/_gm` and `/_gm/*` by
+ // reverse-proxying to the backend operator console after the public
+ // anti-abuse layer (per-IP rate limit, body, and method checks for the
+ // admin route class) has run. Authentication is delegated to the
+ // backend's admin Basic Auth, whose 401 challenge passes straight back
+ // to the browser. When nil, the admin console surface is not mounted.
+ AdminConsoleProxy http.Handler
}
// Server owns the public unauthenticated REST listener exposed by the gateway.
@@ -229,6 +242,8 @@ type defaultPublicTrafficClassifier struct{}
// later drive anti-abuse policy and rate limiting.
func (defaultPublicTrafficClassifier) Classify(r *http.Request) PublicRouteClass {
switch {
+ case isAdminConsoleRequest(r):
+ return PublicRouteClassAdmin
case isPublicAuthRequest(r):
return PublicRouteClassPublicAuth
case isBrowserBootstrapRequest(r):
@@ -290,6 +305,12 @@ func newPublicHandlerWithConfig(cfg config.PublicHTTPConfig, deps ServerDependen
router.POST("/api/v1/public/auth/send-email-code", handleSendEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout))
router.POST("/api/v1/public/auth/confirm-email-code", handleConfirmEmailCode(deps.AuthService, cfg.AuthUpstreamTimeout))
+ if deps.AdminConsoleProxy != nil {
+ adminConsole := gin.WrapH(deps.AdminConsoleProxy)
+ router.Any("/_gm", adminConsole)
+ router.Any("/_gm/*proxyPath", adminConsole)
+ }
+
router.NoMethod(func(c *gin.Context) {
allowMethods := allowedMethodsForPath(c.Request.URL.Path)
if allowMethods != "" {
diff --git a/gateway/internal/restapi/server_test.go b/gateway/internal/restapi/server_test.go
index e21fd3f..0774cb1 100644
--- a/gateway/internal/restapi/server_test.go
+++ b/gateway/internal/restapi/server_test.go
@@ -169,6 +169,31 @@ func TestDefaultPublicTrafficClassifier(t *testing.T) {
accept: "text/html",
wantClass: PublicRouteClassBrowserBootstrap,
},
+ {
+ name: "admin console root",
+ method: http.MethodGet,
+ target: "/_gm",
+ wantClass: PublicRouteClassAdmin,
+ },
+ {
+ name: "admin console page wins over browser accept header",
+ method: http.MethodGet,
+ target: "/_gm/users",
+ accept: "text/html",
+ wantClass: PublicRouteClassAdmin,
+ },
+ {
+ name: "admin console asset wins over browser asset shape",
+ method: http.MethodGet,
+ target: "/_gm/assets/console.css",
+ wantClass: PublicRouteClassAdmin,
+ },
+ {
+ name: "admin console form post",
+ method: http.MethodPost,
+ target: "/_gm/users/123/sanctions",
+ wantClass: PublicRouteClassAdmin,
+ },
}
for _, tt := range tests {
@@ -215,6 +240,11 @@ func TestPublicRouteClassNormalized(t *testing.T) {
input: PublicRouteClassPublicMisc,
want: PublicRouteClassPublicMisc,
},
+ {
+ name: "admin",
+ input: PublicRouteClassAdmin,
+ want: PublicRouteClassAdmin,
+ },
{
name: "unknown collapses to misc",
input: PublicRouteClass("unexpected"),
diff --git a/integration/admin_console_test.go b/integration/admin_console_test.go
new file mode 100644
index 0000000..2f378ff
--- /dev/null
+++ b/integration/admin_console_test.go
@@ -0,0 +1,63 @@
+package integration_test
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "galaxy/integration/testenv"
+)
+
+// TestAdminConsole_ThroughGateway verifies the full operator-console pipe:
+// the edge gateway reverse-proxies `/_gm/*` to the backend, the backend
+// enforces the admin Basic Auth and relays its 401 challenge unchanged, and an
+// authenticated request renders the server-side dashboard.
+func TestAdminConsole_ThroughGateway(t *testing.T) {
+ plat := testenv.Bootstrap(t, testenv.BootstrapOptions{})
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ base := plat.Gateway.HTTPURL
+ client := &http.Client{Timeout: 10 * time.Second}
+
+ // Unauthenticated: the backend's 401 + Basic challenge must reach the client
+ // through the gateway.
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
+ if err != nil {
+ t.Fatalf("build request: %v", err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("GET /_gm/ (no auth): %v", err)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("no-auth status = %d, want 401; body=%s", resp.StatusCode, body)
+ }
+ if !strings.Contains(resp.Header.Get("WWW-Authenticate"), "Basic") {
+ t.Fatalf("WWW-Authenticate = %q, want a Basic challenge", resp.Header.Get("WWW-Authenticate"))
+ }
+
+ // Authenticated with the bootstrap operator: the dashboard renders.
+ req, err = http.NewRequestWithContext(ctx, http.MethodGet, base+"/_gm/", nil)
+ if err != nil {
+ t.Fatalf("build request: %v", err)
+ }
+ req.SetBasicAuth(plat.Backend.AdminUser, plat.Backend.AdminPassword)
+ resp, err = client.Do(req)
+ if err != nil {
+ t.Fatalf("GET /_gm/ (auth): %v", err)
+ }
+ body, _ = io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("authenticated status = %d, want 200; body=%s", resp.StatusCode, body)
+ }
+ if !strings.Contains(string(body), "Dashboard") {
+ t.Fatalf("dashboard body missing the heading; got: %s", body)
+ }
+}
diff --git a/tools/dev-deploy/Caddyfile.dev b/tools/dev-deploy/Caddyfile.dev
index b1acfb5..af25751 100644
--- a/tools/dev-deploy/Caddyfile.dev
+++ b/tools/dev-deploy/Caddyfile.dev
@@ -29,6 +29,14 @@
reverse_proxy galaxy-api:8080
}
+ # Operator console. Shares the gateway public listener with `/api`; the
+ # gateway applies the admin anti-abuse class and reverse-proxies to the
+ # backend `/_gm` surface, which enforces Basic Auth and renders the pages.
+ @gm path /_gm /_gm/*
+ handle @gm {
+ reverse_proxy galaxy-api:8080
+ }
+
# Bare `/game` (no trailing slash) -> `/game/` so the SPA root
# resolves before the site catch-all can claim it.
handle /game {
diff --git a/tools/dev-deploy/docker-compose.yml b/tools/dev-deploy/docker-compose.yml
index 2813833..23e260e 100644
--- a/tools/dev-deploy/docker-compose.yml
+++ b/tools/dev-deploy/docker-compose.yml
@@ -109,7 +109,18 @@ services:
BACKEND_MAIL_WORKER_INTERVAL: 500ms
BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms
BACKEND_OTEL_TRACES_EXPORTER: none
- BACKEND_OTEL_METRICS_EXPORTER: none
+ # Prometheus metrics are enabled in dev so the `/metrics` scrape
+ # endpoint is live and stable ahead of standing up a Prometheus +
+ # Grafana stack on the internal network. The listener stays internal
+ # (not mapped to the host); nothing scrapes it yet.
+ BACKEND_OTEL_METRICS_EXPORTER: prometheus
+ BACKEND_OTEL_PROMETHEUS_LISTEN_ADDR: ":9100"
+ # Operator console (`/_gm`): Basic Auth bootstrap account plus the
+ # stateless CSRF key. Dev-only non-secrets, overridable via `.env`; a
+ # stable CSRF key keeps console forms valid across redeploys.
+ BACKEND_ADMIN_BOOTSTRAP_USER: ${BACKEND_ADMIN_BOOTSTRAP_USER:-gm}
+ BACKEND_ADMIN_BOOTSTRAP_PASSWORD: ${BACKEND_ADMIN_BOOTSTRAP_PASSWORD:-gm-dev-password}
+ BACKEND_ADMIN_CONSOLE_CSRF_KEY: ${BACKEND_ADMIN_CONSOLE_CSRF_KEY:-dev-admin-console-csrf-key}
# Long-lived dev environment always opts into the fixed-code
# override so a returning developer can sign in with `123456`
# even after the matching browser session was cleared (the real
@@ -180,6 +191,10 @@ services:
GATEWAY_LOG_LEVEL: info
GATEWAY_PUBLIC_HTTP_ADDR: ":8080"
GATEWAY_AUTHENTICATED_GRPC_ADDR: ":9090"
+ # Private admin listener exposes the Prometheus `/metrics` endpoint on
+ # the internal network — live and stable for a future scrape, not
+ # mapped to the host.
+ GATEWAY_ADMIN_HTTP_ADDR: ":9191"
GATEWAY_BACKEND_HTTP_URL: "http://galaxy-backend:8080"
GATEWAY_BACKEND_GRPC_PUSH_URL: "galaxy-backend:8081"
GATEWAY_BACKEND_GATEWAY_CLIENT_ID: dev-gateway-1
@@ -208,6 +223,9 @@ services:
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_MISC_RATE_LIMIT_BURST: "1000"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_BOOTSTRAP_MAX_BODY_BYTES: "65536"
GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_BROWSER_ASSET_MAX_BODY_BYTES: "65536"
+ GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_MAX_BODY_BYTES: "131072"
+ GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_REQUESTS: "10000"
+ GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_RATE_LIMIT_BURST: "1000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_REQUESTS: "10000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_IP_RATE_LIMIT_BURST: "1000"
GATEWAY_AUTHENTICATED_GRPC_ANTI_ABUSE_SESSION_RATE_LIMIT_REQUESTS: "10000"