Compare commits

...

16 Commits

Author SHA1 Message Date
developer a19512adaa Merge pull request 'dev-deploy: production mirror + full observability behind the /_gm gate' (#88) from feature/dev-prod-mirror into development
Deploy · Dev / deploy (push) Successful in 42s
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (push) Successful in 1m44s
Tests · UI / test (push) Successful in 3m20s
2026-06-01 04:56:45 +00:00
Ilia Denisov 814eae0802 docs: observability stack + the single /_gm gate for Grafana/Mailpit
Tests · Go / test (pull_request) Successful in 1m56s
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · UI / test (pull_request) Successful in 3m23s
- ARCHITECTURE §17: the dev (production-mirror) collection stack
  (Prometheus / Loki / Tempo / promtail / node-exporter / cAdvisor) and
  the single /_gm Basic Auth gate fronting Grafana and the Mailpit UI.
- tools/dev-deploy/monitoring/README.md (new): services, what is
  collected, Grafana-behind-the-gate access, config delivery, tuning.
- tools/dev-deploy/README.md: an Observability section; the Mailpit UI
  under /_gm/mailpit/; Networking diagram and Files list updated.
- FUNCTIONAL §10.2.1 (+ ru mirror): the operator console nav links to
  Grafana and Mailpit under the same /_gm gate, one sign-in for all.
2026-06-01 06:37:24 +02:00
Ilia Denisov cb8491c200 feat(dev-deploy): one /_gm gate for console + Grafana + Mailpit
Tests · Go / test (push) Successful in 1m59s
Consolidate the operator console and the observability / captured-mail
UIs behind a single Basic Auth gate, so one password (the admin-console
account, dev: gm/gm-dev-password) unlocks all three, with links in the
console nav:

- Caddyfile.dev: a single basic_auth on /_gm/* fronts nested routes —
  /_gm/grafana/ -> Grafana, /_gm/mailpit/ -> Mailpit, catch-all -> the
  gateway/backend console. Caddy forwards the same Authorization header,
  which the backend console also accepts, so there is one prompt. The
  former top-level /grafana/ and /mailpit/ routes are removed.
- Grafana: served under /_gm/grafana/ (sub-path) as anonymous Admin with
  the login form and basic auth disabled, so it relies solely on the
  /_gm gate and ignores the forwarded credentials.
- Mailpit: MP_WEBROOT=/_gm/mailpit (and the healthcheck path) so its UI
  lives under the gate.
- Operator console: add Grafana and Mailpit links to the nav.
2026-06-01 06:30:15 +02:00
Ilia Denisov 45815c27d9 fix(dev-deploy): probe Mailpit /mailpit/livez under MP_WEBROOT
MP_WEBROOT=/mailpit prefixes every Mailpit HTTP route, including the
/livez health endpoint. The container healthcheck still probed
http://localhost:8025/livez, which now 404s, so Mailpit reported
unhealthy; the backend depends_on it with condition: service_healthy
and never started, cascading to the gateway and Caddy and failing
`docker compose up --wait`. Point the healthcheck at /mailpit/livez.
2026-06-01 06:11:25 +02:00
Ilia Denisov e11092234c feat(dev-deploy): expose Grafana + Mailpit UIs via Caddy; seed monitoring config
Deploy wiring for the observability stack (the services and collector
config landed in the previous commit):

- Caddyfile.dev: route /grafana/* to galaxy-grafana:3000 (Caddy
  sub-path mode, Grafana keeps its own login) and /mailpit/* to
  galaxy-mailpit:8025 behind dev basic-auth, so the captured-mail UI
  (every message, relayed or not) and Grafana are reachable through the
  single dev origin.
- dev-deploy.yaml: seed the monitoring config tree to a stable,
  reboot-surviving host path (GALAXY_DEV_MONITORING_DIR) before bringing
  the stack up, and inject the Grafana admin password from a Gitea
  secret (GALAXY_DEV_GRAFANA_ADMIN_PASSWORD; empty falls back to the
  compose default).
2026-06-01 05:46:19 +02:00
Ilia Denisov 84a0ccb23f feat(dev-deploy): full observability stack (Prometheus/Grafana/Loki/Tempo)
Stand up a production-mirror monitoring stack in the long-lived dev
contour, all on galaxy-dev-internal with no host ports (reached only via
the in-repo galaxy-dev-caddy):

- Prometheus scrapes backend:9100, gateway:9191, node-exporter and
  cadvisor (30s interval, 15d retention); Loki (7d) + promtail (Docker
  service discovery by the galaxy.stack=dev-deploy label) for logs;
  Tempo (3d) for traces.
- Backend and gateway now export OTLP traces to Tempo over plaintext
  gRPC on the internal network (OTEL_EXPORTER_OTLP_INSECURE).
- Grafana provisioned as code (Prometheus/Loki/Tempo datasources plus a
  starter dashboard), served under /grafana/ via Caddy sub-path mode;
  admin password from the GALAXY_DEV_GRAFANA_ADMIN_PASSWORD secret.
- Expose the Mailpit capture UI under /mailpit/ (Caddy basic-auth +
  MP_WEBROOT) so every captured message is readable regardless of relay.
- dev-deploy.yaml seeds the monitoring config to a stable, reboot-
  surviving host path and injects the Grafana admin secret.

Per-service memory limits keep the footprint within budget. All
collector config lives under tools/dev-deploy/monitoring/ for dev/prod
parity.
2026-05-31 23:39:06 +02:00
Ilia Denisov 7fb6a63c2b feat(dev-deploy): relay Mailpit to Gmail (Stage 3)
Keep Mailpit as the backend's SMTP submission point and turn on its
relay so OTP/notification mail addressed to the owner reaches a real
Gmail inbox, while everything else stays captured-only.

- mailpit gains --smtp-relay-config + --smtp-relay-matching (default
  non-routable, so an unconfigured stack only captures); relay.conf is
  mounted from a new galaxy-dev-mailpit-config volume
- tools/dev-deploy/mailpit/relay.conf.tmpl + a dev-deploy.yaml step that
  renders it from Gitea secrets (Gmail App Password, never committed)
  and seeds the volume; the GALAXY_DEV_MAIL_RELAY_MATCH var drives the
  relay-matching recipient
- backend SMTP config unchanged (still -> galaxy-mailpit:1025)
- dev-deploy README documents the relay + required secrets/vars

Verified locally: compose config valid; the rendered relay.conf is
accepted by mailpit v1.21.8 (relay + recipient-matching enabled).
Real Gmail delivery is verified at the dev-deploy preview once the
owner sets the secrets.
2026-05-31 22:44:32 +02:00
Ilia Denisov 225f89fad6 docs(ui): correct the synthetic-report loader gate comment
Tests · UI / test (push) Successful in 3m16s
Stage 2 of the dev-as-prod-mirror rework. The legacy-report (synthetic)
report loader is already available in the dev-deploy UI: it is gated by
the build-time flag VITE_GALAXY_DEV_AFFORDANCES (set "true" in
dev-deploy.yaml line 89, unset in prod-build.yaml so prod strips it),
not by import.meta.env.DEV. Correct the stale header comment that
claimed import.meta.env.DEV. No functional change — the desired
"loader in dev, absent in prod" posture already holds.
2026-05-31 22:33:32 +02:00
Ilia Denisov 0cae89cba2 refactor(dev): remove the dev-sandbox bootstrap everywhere
Tests · Go / test (push) Successful in 1m59s
Stage 1 of the dev-as-prod-mirror rework. The auto-provisioned "Dev
Sandbox" game and dummy users are removed so the dev contour starts
empty like prod; the separate legacy-report loader stays as the
test-data path.

- delete backend/internal/devsandbox (package + tests)
- drop the bootstrap call + DevSandboxConfig (struct, Config field,
  BACKEND_DEV_SANDBOX_* env, defaults, loader, validation)
- strip BACKEND_DEV_SANDBOX_* from dev-deploy + local-dev compose and
  .env.example; the generic engine-recycle / prune-broken-engines logic
  stays (it serves real games)
- update tooling docs (dev-deploy README + KNOWN-ISSUES, local-dev
  README + Makefile) and stale comments; DeleteGame and
  InsertMembershipDirect remain (exercised by lobby integration tests)

No app behaviour change beyond not auto-creating the sandbox game.
2026-05-31 22:28:03 +02:00
developer 26f1e62924 Merge pull request 'feat(admin-console): server-rendered operator console at /_gm' (#87) from feature/admin-console into development
Deploy · Dev / deploy (push) Failing after 37s
Tests · Go / test (push) Successful in 1m58s
Tests · Integration / integration (push) Successful in 1m43s
2026-05-31 19:07:47 +00:00
Ilia Denisov 7cac910de4 feat(admin-console): Stage 6 — mail & notifications domain
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m43s
Add the mail, notifications, and broadcast pages over the mail, notification,
and diplomail services (no new business logic), completing the operator console.

- GET  /_gm/mail                         deliveries (paginated) + dead-letters
- GET  /_gm/mail/deliveries/{id}         delivery detail + attempts
- POST /_gm/mail/deliveries/{id}/resend  re-enqueue a non-sent delivery
- GET  /_gm/notifications                notifications + dead-letters + malformed
- GET/POST /_gm/broadcast                multi-game admin diplomatic broadcast

Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces
(satisfied by the concrete services); pages render in tests without a database.
Delivery detail and dead-letters live under /_gm/mail/deliveries/* and
/_gm/mail/... static segments to avoid a param/static route conflict. Resend
and broadcast flow through the CSRF guard.

Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF),
notifications overview, broadcast form + send (input assertions) + bad game
ids, and unavailable. Plus an integration test that drives /_gm end to end
through the real gateway → backend (401 challenge + authenticated dashboard).

Docs: backend/docs/admin-console.md page inventory completed.
2026-05-31 20:43:12 +02:00
Ilia Denisov 87a272166b feat(admin-console): Stage 5 — operators (admin accounts)
Tests · Go / test (push) Successful in 1m59s
Add the operator-management page over *admin.Service (no new business logic).

- GET/POST /_gm/operators                       list + create operator
- POST     /_gm/operators/{user}/disable|enable  toggle access
- POST     /_gm/operators/{user}/reset-password  set a new password

Console depends on an OperatorAdmin interface (satisfied by *admin.Service) so
the page renders in tests without a database. Create POST is mounted on the
collection path; per-row disable/enable/reset are guarded by the CSRF middleware
and redirect back. Passwords are never logged.

Tests: list render, create (+ username/password assertions), username-taken
conflict, disable/enable, reset (+ password assertion), missing-password 400,
bad-CSRF 403, and unavailable 503.

Docs: backend/docs/admin-console.md page inventory extended.
2026-05-31 20:31:16 +02:00
Ilia Denisov ecfb2d3351 feat(admin-console): Stage 4 — games & runtimes domain
Tests · Go / test (push) Successful in 1m58s
Add the games, runtime, and engine-version pages over the existing lobby,
runtime, and engine-version services (no new business logic).

- GET/POST /_gm/games                         list + create public game
- GET      /_gm/games/{id}                    detail incl. runtime snapshot
- POST     /_gm/games/{id}/force-start|stop    game state actions
- POST     /_gm/games/{id}/ban-member          ban a member (uuid + reason)
- POST     /_gm/games/{id}/runtime/restart|patch|force-next-turn
- GET/POST /_gm/engine-versions               registry + register
- POST     /_gm/engine-versions/{ver}/disable disable a version

Console depends on GameAdmin / RuntimeAdmin / EngineVersionAdmin interfaces
(satisfied by the concrete services) so the pages render in tests without a
database. Collection-mutating POSTs are mounted on the collection path to avoid
a static-vs-param route conflict in gin. Writes flow through the CSRF guard and
redirect back; the create form parses datetime-local as UTC.

Tests: list/detail (with and without a runtime), create (visibility/owner/time
assertions), force-start (+ bad-CSRF), ban-member (+ bad uuid), runtime patch
(+ missing version), engine-version list/register/disable, and unavailable.

Docs: backend/docs/admin-console.md page inventory extended.
2026-05-31 20:25:28 +02:00
Ilia Denisov cf34710b4f feat(admin-console): Stage 3 — users domain
Tests · Go / test (push) Successful in 1m56s
Add the operator console's user-administration pages over the existing
*user.Service (no new business logic).

- GET  /_gm/users            paginated account list
- GET  /_gm/users/{id}       account detail: profile, entitlement, sanctions
- POST /_gm/users/{id}/block        apply permanent_block (reason required)
- POST /_gm/users/{id}/entitlement  set the entitlement tier
- POST /_gm/users/{id}/soft-delete  soft-delete the account (cascades)

The console depends on a UserAdmin interface (satisfied by *user.Service) so the
pages render in tests without a database. All writes flow through the CSRF
guard, carry the operator as the audit actor, and answer with a 303 redirect;
a generic message page handles not-found, validation, and failure notices.
Unblock is intentionally absent — the admin API exposes no remove-sanction
endpoint.

Tests: list/detail render, not-found, block (with actor/scope/reason
assertions), missing-reason 400, bad-CSRF 403, entitlement, soft-delete
redirect, and the service-unavailable path.

Docs: backend/docs/admin-console.md gains the page inventory.
2026-05-31 20:15:19 +02:00
Ilia Denisov 985e51d25e feat(admin-console): Stage 2 — dashboard monitoring
Tests · Go / test (push) Successful in 1m58s
Turn the console landing page into an operational dashboard.

- new internal/opsstatus: read-only Postgres projection via go-jet — ping +
  per-status COUNT/GROUP BY on runtime_records, mail_deliveries,
  notification_routes, and a malformed-intent count; degrades per-probe into
  Snapshot.Errors rather than failing the page
- dashboard renders backend readiness, database health, the three status
  tables, the malformed count, and any collection errors; falls back to a
  "monitoring not wired" note when no reader is injected
- AdminConsoleHandlers now takes an AdminConsoleDeps struct (Monitor + Ready
  added) so later stages add service refs without churning the signature

Tests: opsstatus store test against a Postgres testcontainer (empty schema +
one enqueued delivery); dashboard render tests with a fake reader (with and
without monitoring).

Docs: ARCHITECTURE 14.1 + FUNCTIONAL 10.2.1 (+ru) describe the dashboard.
(Prometheus /metrics exporters were already enabled in dev-deploy in Stage 1.)
2026-05-31 20:04:48 +02:00
Ilia Denisov 27916bbe61 feat(admin-console): Stage 1 — pipe + skeleton behind the gateway
Tests · Go / test (push) Successful in 2m0s
Add the server-rendered operator console at /_gm, exposed publicly through
the gateway behind the existing admin_accounts Basic Auth.

Backend:
- new internal/adminconsole package (html/template Renderer, stateless HMAC
  CSRF signer, embedded stylesheet)
- /_gm route group reusing basicauth.Middleware(admin.Service) + a CSRF guard
  (per-operator token + same-origin check); dashboard landing page
- BACKEND_ADMIN_CONSOLE_CSRF_KEY config (per-process random fallback)

Gateway:
- new "admin" public route class (per-IP rate limit, body + GET/HEAD/POST
  method limits) classifying /_gm traffic
- reverse proxy to the backend /_gm surface, preserving Host and relaying the
  backend 401 Basic Auth challenge; 502 when the backend is unreachable
- GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_ADMIN_* config

dev-deploy:
- Caddy routes /_gm/* to the gateway
- bootstrap admin + stable CSRF key; enable Prometheus /metrics exporters on
  backend and gateway (forward-compat for a future Prometheus/Grafana stack)

Docs: ARCHITECTURE 14.1/16, FUNCTIONAL 10.2.1 (+ru mirror), backend and
gateway READMEs, new backend/docs/admin-console.md.

Tests: renderer + CSRF unit tests; backend router auth/render/asset/CSRF;
gateway classifier, proxy forwarding/Host/401/405/413/429/502.
2026-05-31 19:50:15 +02:00
80 changed files with 5603 additions and 750 deletions
+40 -5
View File
@@ -148,12 +148,37 @@ jobs:
-v "${{ gitea.workspace }}/pkg/geoip/test-data/test-data:/src:ro" \
alpine sh -c 'cp /src/GeoIP2-Country-Test.mmdb /dst/geoip.mmdb'
- name: Seed mailpit relay config
env:
GALAXY_DEV_MAIL_RELAY_USERNAME: ${{ secrets.GALAXY_DEV_MAIL_RELAY_USERNAME }}
GALAXY_DEV_MAIL_RELAY_PASSWORD: ${{ secrets.GALAXY_DEV_MAIL_RELAY_PASSWORD }}
run: |
# Render the Mailpit relay upstream config from the template,
# substituting the Gmail App Password from a Gitea secret, then
# seed it into a named volume (same rationale as the geoip seed:
# a workspace bind-mount would vanish with the runner workspace).
# The secret never lands in git or a committed file; it is
# rendered to a tmpfile outside the repo and removed after. Gmail
# App Passwords are [a-z]{16}, so the `|` sed delimiter is safe.
# When the secret is unset the creds render empty and the compose
# default relay-match is non-routable, so the stack only captures.
rendered="$(mktemp)"
sed -e "s|\${GALAXY_DEV_MAIL_RELAY_USERNAME}|${GALAXY_DEV_MAIL_RELAY_USERNAME}|g" \
-e "s|\${GALAXY_DEV_MAIL_RELAY_PASSWORD}|${GALAXY_DEV_MAIL_RELAY_PASSWORD}|g" \
"${{ gitea.workspace }}/tools/dev-deploy/mailpit/relay.conf.tmpl" > "$rendered"
docker volume create galaxy-dev-mailpit-config >/dev/null
docker run --rm \
-v galaxy-dev-mailpit-config:/dst \
-v "$rendered:/src/relay.conf:ro" \
alpine sh -c 'cp /src/relay.conf /dst/relay.conf && chmod 600 /dst/relay.conf'
rm -f "$rendered"
- name: Recycle engine containers on image drift
run: |
# Compare the freshly-built `galaxy-engine:dev` SHA against
# every running `galaxy-game-*` container. The backend
# reconciler adopts pre-existing labelled engine containers
# without checking image drift, so a running sandbox would
# without checking image drift, so a running game would
# otherwise keep serving the previous engine code until the
# container is recycled by hand. This step makes the recycle
# automatic but only when it is actually needed:
@@ -168,10 +193,7 @@ jobs:
# silent state corruption otherwise), and cascade-delete
# the lobby `games` row (the FKs in `00001_init.sql`
# drop the matching `runtime_records`, `memberships`,
# `player_mappings`, etc. in the same write). The
# `dev-sandbox` bootstrap on the next backend boot finds
# no live sandbox and provisions a fresh one on the new
# engine image.
# `player_mappings`, etc. in the same write).
#
# Backend is stopped first to keep the reconciler from
# racing the recycle (mid-stream adoption / restart). The
@@ -234,11 +256,24 @@ jobs:
- name: Bring up the stack
working-directory: tools/dev-deploy
env:
# Recipient regex Mailpit auto-relays to the owner's Gmail.
# Unset/empty → the compose default (non-routable) keeps the
# stack capture-only.
GALAXY_DEV_MAIL_RELAY_MATCH: ${{ vars.GALAXY_DEV_MAIL_RELAY_MATCH }}
# Grafana admin password; unset/empty -> compose default 'admin'.
GALAXY_DEV_GRAFANA_ADMIN_PASSWORD: ${{ secrets.GALAXY_DEV_GRAFANA_ADMIN_PASSWORD }}
run: |
# Resolve in the shell, not in YAML expressions — `env.HOME`
# is empty at the workflow-evaluation stage.
export GALAXY_DEV_GAME_STATE_DIR="$HOME/.galaxy-dev/game-state"
mkdir -p "$GALAXY_DEV_GAME_STATE_DIR"
# Seed the monitoring config to a stable, reboot-surviving host
# path (compose binds \${GALAXY_DEV_MONITORING_DIR} read-only).
export GALAXY_DEV_MONITORING_DIR="$HOME/.galaxy-dev/monitoring"
rm -rf "$GALAXY_DEV_MONITORING_DIR"
mkdir -p "$GALAXY_DEV_MONITORING_DIR"
cp -r monitoring/. "$GALAXY_DEV_MONITORING_DIR/"
docker compose up -d --wait --remove-orphans
- name: Probe the stack
+8 -1
View File
@@ -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`. |
+35 -18
View File
@@ -22,10 +22,10 @@ import (
_ "time/tzdata"
"galaxy/backend/internal/admin"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/app"
"galaxy/backend/internal/auth"
"galaxy/backend/internal/config"
"galaxy/backend/internal/devsandbox"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/diplomail/detector"
"galaxy/backend/internal/diplomail/translator"
@@ -37,6 +37,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"
@@ -272,29 +273,18 @@ func run(ctx context.Context) (err error) {
)
runtimeGateway.svc = runtimeSvc
// Run a single reconciliation pass before the dev-sandbox
// bootstrap so any runtime row pointing at a vanished engine
// container (host reboot wiped /tmp/galaxy-game-state/<uuid>;
// `tools/local-dev`'s `prune-broken-engines` target reaped the
// husk) is already cascaded through `markRemoved` → lobby
// `cancelled` by the time the bootstrap walks the sandbox list.
// Without this pre-tick the bootstrap would reuse the
// soon-to-be-cancelled game and force the developer into a
// second `make up` cycle to land a healthy sandbox. Failures are
// Run a single reconciliation pass at startup so any runtime row
// pointing at a vanished engine container (a host reboot wiped
// /tmp/galaxy-game-state/<uuid>; `tools/local-dev`'s
// `prune-broken-engines` target reaped the husk) is cascaded
// through `markRemoved` → lobby `cancelled` before the server
// starts serving requests. Failures are
// non-fatal: the periodic ticker started later catches up, and
// the worst case degrades to the legacy two-cycle recovery.
if err := runtimeSvc.Reconciler().Tick(ctx); err != nil {
logger.Warn("pre-bootstrap reconciler tick failed", zap.Error(err))
}
if err := devsandbox.Bootstrap(ctx, devsandbox.Deps{
Users: userSvc,
Lobby: lobbySvc,
EngineVersions: engineVersionSvc,
}, cfg.DevSandbox, logger); err != nil {
return fmt.Errorf("dev sandbox bootstrap: %w", err)
}
notifStore := notification.NewStore(db)
notifSvc := notification.NewService(notification.Deps{
Store: notifStore,
@@ -360,6 +350,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 +404,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)
+128
View File
@@ -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. |
@@ -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; }
+54
View File
@@ -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))
}
@@ -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")
}
}
@@ -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
}
+18
View File
@@ -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
+67
View File
@@ -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
}
+86
View File
@@ -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
}
+11
View File
@@ -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
}
@@ -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
}
+107
View File
@@ -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")
}
@@ -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{
"<!DOCTYPE html>",
"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: "<script>evil</script>"}); err != nil {
t.Fatalf("Render: %v", err)
}
if strings.Contains(buf.String(), "<script>evil</script>") {
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)
}
}
@@ -0,0 +1,30 @@
{{define "layout" -}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>{{.Title}} · Galaxy GM</title>
<link rel="stylesheet" href="/_gm/assets/console.css">
</head>
<body>
<header class="topbar">
<span class="brand">Galaxy · GM</span>
<nav class="mainnav">
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/operators"{{if eq .ActiveNav "operators"}} class="active"{{end}}>Operators</a>
<a href="/_gm/mail"{{if eq .ActiveNav "mail"}} class="active"{{end}}>Mail</a>
<a href="/_gm/grafana/" target="_blank" rel="noopener">Grafana</a>
<a href="/_gm/mailpit/" target="_blank" rel="noopener">Mailpit</a>
</nav>
<span class="who">{{.Username}}</span>
</header>
<main class="content">
{{template "content" .}}
</main>
</body>
</html>
{{- end}}
@@ -0,0 +1,21 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Broadcast</h1>
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
<section class="panel">
<h2>Admin broadcast</h2>
<form method="post" action="/_gm/broadcast" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Scope
<select name="scope"><option value="all_running">all running games</option><option value="selected">selected games</option></select>
</label>
<label>Game IDs (comma-separated, for "selected") <input type="text" name="game_ids" placeholder="uuid,uuid"></label>
<label>Recipients
<select name="recipients"><option value="active">active members</option><option value="active_and_removed">active and removed</option><option value="all_members">all members</option></select>
</label>
<label>Subject <input type="text" name="subject"></label>
<label>Body <input type="text" name="body" required></label>
<button type="submit">Send broadcast</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,69 @@
{{define "content" -}}
<h1>Dashboard</h1>
<p class="lede">Signed in as <strong>{{.Username}}</strong>.</p>
{{with .Data}}
<section class="panel">
<h2>Health</h2>
<ul class="kv">
<li>Backend ready: {{if .BackendReady}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</li>
<li>Postgres: {{if .PostgresHealthy}}<span class="ok">healthy</span>{{else}}<span class="bad">unreachable</span>{{end}}</li>
</ul>
</section>
{{if .MonitorAvailable}}
<div class="grid">
<section class="panel">
<h2>Game runtimes</h2>
{{template "statuscounts" .Runtimes}}
</section>
<section class="panel">
<h2>Mail deliveries</h2>
{{template "statuscounts" .MailDeliveries}}
</section>
<section class="panel">
<h2>Notification routes</h2>
{{template "statuscounts" .NotificationRoutes}}
</section>
<section class="panel">
<h2>Malformed notifications</h2>
<p class="bignum {{if gt .NotificationMalformed 0}}bad{{end}}">{{.NotificationMalformed}}</p>
</section>
</div>
{{if .Errors}}
<section class="panel errors">
<h2>Collection errors</h2>
<ul>{{range .Errors}}<li>{{.}}</li>{{end}}</ul>
</section>
{{end}}
{{else}}
<p class="note">Monitoring is not wired in this deployment.</p>
{{end}}
{{end}}
<section class="cards">
<a class="card" href="/_gm/users">
<h2>Users</h2>
<p>Accounts, sanctions, entitlements, soft-delete.</p>
</a>
<a class="card" href="/_gm/games">
<h2>Games &amp; runtimes</h2>
<p>Lobby state, engine versions, turn control.</p>
</a>
<a class="card" href="/_gm/operators">
<h2>Operators</h2>
<p>Admin accounts: create, disable, reset password.</p>
</a>
<a class="card" href="/_gm/mail">
<h2>Mail &amp; notifications</h2>
<p>Deliveries, dead-letters, broadcasts.</p>
</a>
</section>
{{- end}}
{{define "statuscounts" -}}
{{if .}}
<table class="counts"><tbody>
{{range .}}<tr><td>{{.Status}}</td><td class="num">{{.Count}}</td></tr>{{end}}
</tbody></table>
{{else}}
<p class="note">none</p>
{{end}}
{{- end}}
@@ -0,0 +1,30 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Engine versions</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Version</th><th>Image</th><th>Enabled</th><th>Created</th><th></th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Version}}</td>
<td><code>{{.ImageRef}}</code></td>
<td>{{if .Enabled}}<span class="ok">yes</span>{{else}}<span class="bad">no</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{if .Enabled}}<form method="post" action="/_gm/engine-versions/{{.Version}}/disable" onsubmit="return confirm('Disable {{.Version}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>{{end}}</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no engine versions</span></td></tr>{{end}}
</tbody>
</table>
{{end}}
<section class="panel">
<h2>Register version</h2>
<form method="post" action="/_gm/engine-versions" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Version <input type="text" name="version" placeholder="semver e.g. 0.1.0" required></label>
<label>Image ref <input type="text" name="image_ref" required></label>
<label>Enabled <input type="checkbox" name="enabled" value="true" checked></label>
<button type="submit">Register</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,65 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/games">&laquo; all games</a></p>
<h1>{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</h1>
<section class="panel">
<h2>Game</h2>
<ul class="kv">
<li>Game ID: <code>{{.GameID}}</code></li>
<li>Visibility: {{.Visibility}}</li>
<li>Status: {{.Status}}</li>
<li>Owner: {{.Owner}}</li>
<li>Players: {{.MinPlayers}}{{.MaxPlayers}}</li>
<li>Start gap: {{.StartGapHours}}h / {{.StartGapPlayers}} players</li>
<li>Turn schedule: {{.TurnSchedule}}</li>
<li>Target engine: {{.TargetEngineVersion}}</li>
<li>Enrollment ends: {{.EnrollmentEndsAt}}</li>
<li>Created: {{.CreatedAt}}</li>
<li>Started: {{if .StartedAt}}{{.StartedAt}}{{else}}—{{end}}</li>
<li>Finished: {{if .FinishedAt}}{{.FinishedAt}}{{else}}—{{end}}</li>
</ul>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="actions">
<form method="post" action="/_gm/games/{{.GameID}}/force-start" onsubmit="return confirm('Force-start this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force start</button></form>
<form method="post" action="/_gm/games/{{.GameID}}/force-stop" onsubmit="return confirm('Force-stop this game?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Force stop</button></form>
</div>
</section>
<section class="panel">
<h2>Runtime</h2>
{{if .HasRuntime}}
<ul class="kv">
<li>Status: {{.RuntimeStatus}}</li>
<li>Engine version: {{.CurrentEngineVersion}}</li>
<li>Engine health: {{.EngineHealth}}</li>
<li>Current turn: {{.CurrentTurn}}</li>
<li>Next generation: {{if .NextGenerationAt}}{{.NextGenerationAt}}{{else}}—{{end}}</li>
<li>Paused: {{if .Paused}}yes{{else}}no{{end}}</li>
</ul>
<div class="actions">
<form method="post" action="/_gm/games/{{.GameID}}/runtime/restart" onsubmit="return confirm('Restart the engine container?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Restart</button></form>
<form method="post" action="/_gm/games/{{.GameID}}/runtime/force-next-turn" onsubmit="return confirm('Force the next turn now?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Force next turn</button></form>
</div>
<form method="post" action="/_gm/games/{{.GameID}}/runtime/patch" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Patch to version <input type="text" name="target_version" placeholder="e.g. 0.1.1" required></label>
<button type="submit">Patch</button>
</form>
{{else}}
<p class="note">No runtime record for this game yet.</p>
{{end}}
</section>
<section class="panel">
<h2>Ban member</h2>
<form method="post" action="/_gm/games/{{.GameID}}/ban-member" class="form" onsubmit="return confirm('Ban this member from the game?');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>User ID <input type="text" name="user_id" required></label>
<label>Reason <input type="text" name="reason"></label>
<button type="submit" class="danger">Ban member</button>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,43 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Games</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Name</th><th>Visibility</th><th>Status</th><th>Owner</th><th>Players</th><th>Schedule</th><th>Created</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/games/{{.GameID}}">{{if .GameName}}{{.GameName}}{{else}}(unnamed){{end}}</a></td>
<td>{{.Visibility}}</td>
<td>{{.Status}}</td>
<td>{{.Owner}}</td>
<td>{{.Players}}</td>
<td>{{.TurnSchedule}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/games?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/games?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
{{end}}
<section class="panel">
<h2>Create public game</h2>
<form method="post" action="/_gm/games" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Name <input type="text" name="game_name" required></label>
<label>Description <input type="text" name="description"></label>
<label>Min players <input type="number" name="min_players" value="2" min="1"></label>
<label>Max players <input type="number" name="max_players" value="8" min="1"></label>
<label>Start gap hours <input type="number" name="start_gap_hours" value="0" min="0"></label>
<label>Start gap players <input type="number" name="start_gap_players" value="0" min="0"></label>
<label>Enrollment ends <input type="datetime-local" name="enrollment_ends_at" required></label>
<label>Turn schedule <input type="text" name="turn_schedule" placeholder="e.g. @every 24h" required></label>
<label>Engine version <input type="text" name="target_engine_version" placeholder="e.g. 0.1.0" required></label>
<button type="submit">Create</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,32 @@
{{define "content" -}}
<h1>Mail</h1>
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
{{with .Data}}
<section class="panel">
<h2>Deliveries</h2>
<table class="list">
<thead><tr><th>Delivery</th><th>Template</th><th>Status</th><th>Attempts</th><th>Next attempt</th><th>Created</th></tr></thead>
<tbody>
{{range .Deliveries}}
<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Template}}</td><td>{{.Status}}</td><td>{{.Attempts}}</td><td>{{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</td><td>{{.Created}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no deliveries</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/mail?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/mail?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
</section>
<section class="panel">
<h2>Dead-letters</h2>
<table class="list">
<thead><tr><th>Delivery</th><th>Reason</th><th>Archived</th></tr></thead>
<tbody>
{{range .DeadLetters}}<tr><td><a href="/_gm/mail/deliveries/{{.DeliveryID}}"><code>{{.DeliveryID}}</code></a></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
{{else}}<tr><td colspan="3"><span class="note">no dead-letters</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,33 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/mail">&laquo; mail</a></p>
<h1>Delivery</h1>
<section class="panel">
<ul class="kv">
<li>Delivery ID: <code>{{.DeliveryID}}</code></li>
<li>Template: {{.Template}}</li>
<li>Status: {{.Status}}</li>
<li>Attempts: {{.Attempts}}</li>
<li>Next attempt: {{if .NextAttempt}}{{.NextAttempt}}{{else}}—{{end}}</li>
<li>Created: {{.Created}}</li>
<li>Sent: {{if .Sent}}{{.Sent}}{{else}}—{{end}}</li>
<li>Dead-lettered: {{if .DeadLettered}}{{.DeadLettered}}{{else}}—{{end}}</li>
<li>Last error: {{if .LastError}}{{.LastError}}{{else}}—{{end}}</li>
</ul>
{{if .CanResend}}
<form method="post" action="/_gm/mail/deliveries/{{.DeliveryID}}/resend" class="form" onsubmit="return confirm('Resend this delivery?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Resend</button></form>
{{else}}<p class="note">Already sent — resend is not available.</p>{{end}}
</section>
<section class="panel">
<h2>Attempts</h2>
<table class="list">
<thead><tr><th>#</th><th>Outcome</th><th>Started</th><th>Finished</th><th>Error</th></tr></thead>
<tbody>
{{range .AttemptRows}}<tr><td>{{.AttemptNo}}</td><td>{{.Outcome}}</td><td>{{.Started}}</td><td>{{if .Finished}}{{.Finished}}{{else}}—{{end}}</td><td>{{.Error}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no attempts</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,7 @@
{{define "content" -}}
<h1>{{.Title}}</h1>
{{with .Data}}
<p class="{{.Class}}">{{.Message}}</p>
{{if .BackHref}}<p><a href="{{.BackHref}}">&laquo; back</a></p>{{end}}
{{end}}
{{- end}}
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Notifications</h1>
<nav class="subnav"><a href="/_gm/mail">Deliveries</a> · <a href="/_gm/notifications">Notifications</a> · <a href="/_gm/broadcast">Broadcast</a></nav>
{{with .Data}}
<section class="panel">
<h2>Recent notifications</h2>
<table class="list"><thead><tr><th>ID</th><th>Kind</th><th>User</th><th>Created</th></tr></thead><tbody>
{{range .Notifications}}<tr><td><code>{{.NotificationID}}</code></td><td>{{.Kind}}</td><td>{{.UserID}}</td><td>{{.Created}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
</tbody></table>
</section>
<section class="panel">
<h2>Dead-letters</h2>
<table class="list"><thead><tr><th>Notification</th><th>Route</th><th>Reason</th><th>Archived</th></tr></thead><tbody>
{{range .DeadLetters}}<tr><td><code>{{.NotificationID}}</code></td><td><code>{{.RouteID}}</code></td><td>{{.Reason}}</td><td>{{.Archived}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">none</span></td></tr>{{end}}
</tbody></table>
</section>
<section class="panel">
<h2>Malformed intents</h2>
<table class="list"><thead><tr><th>ID</th><th>Reason</th><th>Received</th></tr></thead><tbody>
{{range .Malformed}}<tr><td><code>{{.ID}}</code></td><td>{{.Reason}}</td><td>{{.Received}}</td></tr>
{{else}}<tr><td colspan="3"><span class="note">none</span></td></tr>{{end}}
</tbody></table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,38 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
<h1>Operators</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Username</th><th>Status</th><th>Created</th><th>Last used</th><th>Actions</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Username}}</td>
<td>{{if .Disabled}}<span class="bad">disabled</span>{{else}}<span class="ok">active</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
<td>{{if .LastUsedAt}}{{.LastUsedAt}}{{else}}—{{end}}</td>
<td>
<div class="actions">
{{if .Disabled}}
<form method="post" action="/_gm/operators/{{.Username}}/enable"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit">Enable</button></form>
{{else}}
<form method="post" action="/_gm/operators/{{.Username}}/disable" onsubmit="return confirm('Disable {{.Username}}?');"><input type="hidden" name="_csrf" value="{{$csrf}}"><button type="submit" class="danger">Disable</button></form>
{{end}}
<form method="post" action="/_gm/operators/{{.Username}}/reset-password" class="form"><input type="hidden" name="_csrf" value="{{$csrf}}"><input type="password" name="password" placeholder="new password" required><button type="submit">Reset</button></form>
</div>
</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no operators</span></td></tr>{{end}}
</tbody>
</table>
{{end}}
<section class="panel">
<h2>Create operator</h2>
<form method="post" action="/_gm/operators" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Username <input type="text" name="username" required></label>
<label>Password <input type="password" name="password" required></label>
<button type="submit">Create</button>
</form>
</section>
{{- end}}
@@ -0,0 +1,68 @@
{{define "content" -}}
{{$csrf := .CSRFToken}}
{{with .Data}}
<p><a href="/_gm/users">&laquo; all users</a></p>
<h1>{{.Email}}</h1>
{{if .Deleted}}<p class="bad">This account is soft-deleted.</p>{{end}}
<section class="panel">
<h2>Account</h2>
<ul class="kv">
<li>User ID: <code>{{.UserID}}</code></li>
<li>User name: {{.UserName}}</li>
<li>Display name: {{.DisplayName}}</li>
<li>Preferred language: {{.PreferredLanguage}}</li>
<li>Time zone: {{.TimeZone}}</li>
<li>Declared country: {{.DeclaredCountry}}</li>
<li>Status: {{if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</li>
<li>Created: {{.CreatedAt}}</li>
<li>Updated: {{.UpdatedAt}}</li>
</ul>
</section>
<section class="panel">
<h2>Entitlement</h2>
<ul class="kv">
<li>Tier: <strong>{{.Tier}}</strong> ({{if .IsPaid}}paid{{else}}free{{end}})</li>
<li>Source: {{.EntitlementSource}}</li>
<li>Reason: {{.EntitlementReason}}</li>
<li>Ends: {{if .EntitlementEnds}}{{.EntitlementEnds}}{{else}}—{{end}}</li>
</ul>
<form method="post" action="/_gm/users/{{.UserID}}/entitlement" class="form">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Tier
<select name="tier">{{range .Tiers}}<option value="{{.}}">{{.}}</option>{{end}}</select>
</label>
<label>Source <input type="text" name="source" value="admin"></label>
<label>Reason <input type="text" name="reason_code" placeholder="optional"></label>
<button type="submit">Update entitlement</button>
</form>
</section>
<section class="panel">
<h2>Active sanctions</h2>
{{if .Sanctions}}
<table class="counts"><tbody>
{{range .Sanctions}}<tr><td>{{.SanctionCode}}</td><td>{{.Scope}}</td><td>{{.ReasonCode}}</td><td>{{.AppliedAt}}</td></tr>{{end}}
</tbody></table>
{{else}}<p class="note">none</p>{{end}}
{{if .Blocked}}
<p class="note">User is permanently blocked. Unblock is not available in the current admin API.</p>
{{else}}
<form method="post" action="/_gm/users/{{.UserID}}/block" class="form" onsubmit="return confirm('Permanently block this user?');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<label>Reason <input type="text" name="reason_code" required></label>
<button type="submit" class="danger">Permanently block</button>
</form>
{{end}}
</section>
<section class="panel">
<h2>Danger zone</h2>
<form method="post" action="/_gm/users/{{.UserID}}/soft-delete" class="form" onsubmit="return confirm('Soft-delete this account? This cascades to sessions, memberships, and owned games.');">
<input type="hidden" name="_csrf" value="{{$csrf}}">
<button type="submit" class="danger">Soft-delete account</button>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,27 @@
{{define "content" -}}
<h1>Users</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Email</th><th>User name</th><th>Display</th><th>Tier</th><th>Status</th><th>Created</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.UserID}}">{{.Email}}</a></td>
<td>{{.UserName}}</td>
<td>{{.DisplayName}}</td>
<td>{{.Tier}}</td>
<td>{{if .Deleted}}<span class="bad">deleted</span>{{else if .Blocked}}<span class="bad">blocked</span>{{else}}<span class="ok">active</span>{{end}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="6"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .HasPrev}}<a href="/_gm/users?page={{.PrevPage}}&amp;page_size={{.PageSize}}">&laquo; prev</a>{{end}}
<span>page {{.Page}} · {{.Total}} total</span>
{{if .HasNext}}<a href="/_gm/users?page={{.NextPage}}&amp;page_size={{.PageSize}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
+61
View File
@@ -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
}
+14 -51
View File
@@ -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"
@@ -103,11 +105,6 @@ const (
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
)
// Default values applied when an environment variable is absent.
@@ -176,9 +173,6 @@ const (
defaultDiplomailTranslatorTimeout = 10 * time.Second
defaultDiplomailTranslatorMaxAttempts = 5
defaultDiplomailWorkerInterval = 2 * time.Second
defaultDevSandboxEngineVersion = "0.1.0"
defaultDevSandboxPlayerCount = 20
)
// Allowed values for the closed-set string options.
@@ -208,6 +202,7 @@ type Config struct {
Docker DockerConfig
Game GameConfig
Admin AdminBootstrapConfig
AdminConsole AdminConsoleConfig
GeoIP GeoIPConfig
Telemetry TelemetryConfig
Auth AuthConfig
@@ -216,29 +211,12 @@ type Config struct {
Runtime RuntimeConfig
Notification NotificationConfig
Diplomail DiplomailConfig
DevSandbox DevSandboxConfig
// FreshnessWindow mirrors the gateway freshness window and is used by the
// push server to bound the cursor TTL.
FreshnessWindow time.Duration
}
// DevSandboxConfig configures the boot-time bootstrap implemented in
// `backend/internal/devsandbox`. When Email is empty the bootstrap
// is a no-op, which is the production posture. When Email is set —
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
// the bootstrap idempotently provisions a real user, the configured
// number of dummy participants, a private "Dev Sandbox" game, the
// matching memberships, and drives the lifecycle to `running`. The
// engine image and engine version refer to a row that the bootstrap
// also seeds in `engine_versions`.
type DevSandboxConfig struct {
Email string
EngineImage string
EngineVersion string
PlayerCount int
}
// LoggingConfig stores the parameters used by the structured logger.
type LoggingConfig struct {
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
@@ -308,6 +286,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
@@ -560,10 +547,6 @@ func DefaultConfig() Config {
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
WorkerInterval: defaultDiplomailWorkerInterval,
},
DevSandbox: DevSandboxConfig{
EngineVersion: defaultDevSandboxEngineVersion,
PlayerCount: defaultDevSandboxPlayerCount,
},
Runtime: RuntimeConfig{
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
JobQueueSize: defaultRuntimeJobQueueSize,
@@ -644,6 +627,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))
@@ -741,13 +726,6 @@ func LoadFromEnv() (Config, error) {
return Config{}, err
}
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
return Config{}, err
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
@@ -959,21 +937,6 @@ func (c Config) Validate() error {
}
}
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
if _, err := netmail.ParseAddress(email); err != nil {
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
}
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
}
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
}
if c.DevSandbox.PlayerCount <= 0 {
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
}
}
return nil
}
-287
View File
@@ -1,287 +0,0 @@
// Package devsandbox provisions a ready-to-play game on backend boot
// for the `tools/local-dev` stack.
//
// Bootstrap is invoked from `backend/cmd/backend/main.go` after the
// admin bootstrap and before the HTTP listener starts. It reads
// `cfg.DevSandbox`; when `Email` is empty (the production posture)
// the function logs "skipped" and returns nil. When set, it
// idempotently:
//
// 1. registers the configured engine version and image;
// 2. find-or-creates the real dev user with the configured email;
// 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy
// users so the engine's minimum-players constraint is met;
// 4. find-or-creates a private "Dev Sandbox" game owned by the
// real user with min/max_players = cfg.PlayerCount and a
// year-out turn schedule (effectively frozen at turn 1);
// 5. inserts memberships for all participants bypassing the
// application/approval flow;
// 6. drives the lifecycle to `running` (or as far as possible if
// the runtime is busy).
//
// The function is a no-op on subsequent boots once the game is
// running; partial states from earlier crashes are recovered.
package devsandbox
import (
"context"
"errors"
"fmt"
"time"
"galaxy/backend/internal/config"
"galaxy/backend/internal/lobby"
"galaxy/backend/internal/runtime"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SandboxGameName is the display name used to identify the
// auto-provisioned game on subsequent reboots. The combination of
// game_name and owner_user_id is unique enough in practice — only
// the dev sandbox bootstrap creates a game owned by the configured
// real user with this exact name.
const SandboxGameName = "Dev Sandbox"
// SandboxTurnSchedule keeps the game on turn 1 by scheduling the
// next turn a year out. The runtime scheduler still parses this and
// will tick once a year — long enough to never interfere with
// solo UI development.
const SandboxTurnSchedule = "0 0 1 1 *"
// UserEnsurer matches `auth.UserEnsurer`. We define a local
// interface to avoid importing the auth package and circular
// dependencies — the production wiring passes the same `*user.Service`
// instance used by auth.
type UserEnsurer interface {
EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error)
}
// Deps aggregates the collaborators Bootstrap needs.
type Deps struct {
Users UserEnsurer
Lobby *lobby.Service
EngineVersions *runtime.EngineVersionService
}
// Bootstrap runs the seven-step provisioning flow described on the
// package doc comment. Errors are returned to the caller; the boot
// path in `cmd/backend/main.go` aborts startup if Bootstrap fails so
// a misconfigured dev environment surfaces immediately rather than
// silently leaving the lobby empty.
func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error {
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("dev_sandbox")
if cfg.Email == "" {
logger.Info("skipped (no email)")
return nil
}
if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil {
return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required")
}
if cfg.PlayerCount <= 0 {
return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount)
}
if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil {
return err
}
realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "")
if err != nil {
return fmt.Errorf("dev_sandbox: ensure real user: %w", err)
}
dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1)
for i := 1; i < cfg.PlayerCount; i++ {
email := fmt.Sprintf("dev-dummy-%02d@local.test", i)
id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "")
if err != nil {
return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err)
}
dummyIDs = append(dummyIDs, id)
}
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
return err
}
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
if err != nil {
return err
}
game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger)
if err != nil {
return err
}
logger.Info("bootstrap complete",
zap.String("user_id", realID.String()),
zap.String("game_id", game.GameID.String()),
zap.String("status", game.Status),
)
return nil
}
func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error {
_, err := svc.Register(ctx, runtime.RegisterInput{
Version: cfg.EngineVersion,
ImageRef: cfg.EngineImage,
})
switch {
case err == nil:
logger.Info("engine version registered",
zap.String("version", cfg.EngineVersion),
zap.String("image", cfg.EngineImage),
)
return nil
case errors.Is(err, runtime.ErrEngineVersionTaken):
logger.Debug("engine version already registered",
zap.String("version", cfg.EngineVersion),
)
return nil
default:
return fmt.Errorf("dev_sandbox: register engine version: %w", err)
}
}
// terminalSandboxStatus reports whether a sandbox game has reached a
// state from which it can no longer be driven back to running. We
// treat such games as "absent" so the next bootstrap creates a fresh
// one rather than handing the developer a dead lobby tile.
func terminalSandboxStatus(status string) bool {
switch status {
case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed:
return true
}
return false
}
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
// the dev user owns that has reached a terminal state
// (cancelled / finished / start_failed). The cascade declared in
// `00001_init.sql` removes the matching memberships, applications,
// invites, runtime records, and player mappings in the same write,
// so the developer's lobby never piles up dead tiles between
// `make rebuild` cycles. Non-terminal games are left untouched —
// a `running` sandbox from a previous boot is the happy path.
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
games, err := svc.ListMyGames(ctx, ownerID)
if err != nil {
return fmt.Errorf("dev_sandbox: list my games: %w", err)
}
for _, g := range games {
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
continue
}
if !terminalSandboxStatus(g.Status) {
continue
}
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
}
logger.Info("purged terminal sandbox game",
zap.String("game_id", g.GameID.String()),
zap.String("status", g.Status),
)
}
return nil
}
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
games, err := svc.ListMyGames(ctx, ownerID)
if err != nil {
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
}
for _, g := range games {
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
continue
}
// `purgeTerminalSandboxGames` ran before us, so any sandbox
// game still in the list is either a live one we should
// reuse or a transient state we can drive forward.
return g, nil
}
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
OwnerUserID: &ownerID,
Visibility: lobby.VisibilityPrivate,
GameName: SandboxGameName,
Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.",
MinPlayers: int32(cfg.PlayerCount),
MaxPlayers: int32(cfg.PlayerCount),
StartGapHours: 0,
StartGapPlayers: 0,
EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour),
TurnSchedule: SandboxTurnSchedule,
TargetEngineVersion: cfg.EngineVersion,
})
if err != nil {
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err)
}
return rec, nil
}
func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) {
caller := realID
if game.Status == lobby.GameStatusDraft {
next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err)
}
game = next
}
if game.Status == lobby.GameStatusEnrollmentOpen {
users := append([]uuid.UUID{realID}, dummyIDs...)
for i, uid := range users {
raceName := fmt.Sprintf("Sandbox-%02d", i+1)
if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{
GameID: game.GameID,
UserID: uid,
RaceName: raceName,
}); err != nil {
return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err)
}
}
logger.Info("memberships ensured",
zap.Int("count", len(users)),
zap.String("game_id", game.GameID.String()),
)
next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: ready to start: %w", err)
}
game = next
}
if game.Status == lobby.GameStatusReadyToStart {
next, err := svc.Start(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: start: %w", err)
}
game = next
}
if game.Status == lobby.GameStatusStartFailed {
next, err := svc.RetryStart(ctx, &caller, false, game.GameID)
if err != nil {
logger.Warn("retry start failed", zap.Error(err))
return game, nil
}
game = next
if game.Status == lobby.GameStatusReadyToStart {
next, err := svc.Start(ctx, &caller, false, game.GameID)
if err != nil {
return game, fmt.Errorf("dev_sandbox: start after retry: %w", err)
}
game = next
}
}
return game, nil
}
@@ -1,106 +0,0 @@
package devsandbox
import (
"context"
"errors"
"testing"
"galaxy/backend/internal/config"
"github.com/google/uuid"
"go.uber.org/zap"
)
// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with
// the production posture (Email == "") Bootstrap must return without
// touching any dependency. The fact that Users/Lobby/EngineVersions
// are nil here doubles as a check that the early-return runs first.
func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) {
err := Bootstrap(
context.Background(),
Deps{},
config.DevSandboxConfig{},
zap.NewNop(),
)
if err != nil {
t.Fatalf("expected nil error on empty email, got: %v", err)
}
}
// TestBootstrapRejectsZeroPlayerCount confirms the validation
// short-circuits the flow before any DB call when PlayerCount is
// non-positive but Email is set. The error path is fast and never
// dereferences the (still-nil) Users/Lobby deps.
func TestBootstrapRejectsZeroPlayerCount(t *testing.T) {
err := Bootstrap(
context.Background(),
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
config.DevSandboxConfig{
Email: "dev@local.test",
EngineImage: "galaxy-engine:local-dev",
EngineVersion: "0.0.0-local-dev",
PlayerCount: 0,
},
zap.NewNop(),
)
if err == nil {
t.Fatal("expected error on zero PlayerCount, got nil")
}
}
// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring
// (Email set but one of the required services nil) fails fast rather
// than panicking when the bootstrap reaches its first service call.
func TestBootstrapRejectsMissingDeps(t *testing.T) {
err := Bootstrap(
context.Background(),
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
config.DevSandboxConfig{
Email: "dev@local.test",
EngineImage: "galaxy-engine:local-dev",
EngineVersion: "0.0.0-local-dev",
PlayerCount: 20,
},
zap.NewNop(),
)
if err == nil {
t.Fatal("expected error on missing deps, got nil")
}
if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" {
// The exact wording is not part of the contract; this branch
// only asserts the error is non-nil and human-readable.
t.Fatalf("error has empty message: %v", err)
}
}
// errMissingDepsSentinel exists so the assertion above can compile;
// the real error is constructed via errors.New inside Bootstrap and
// is intentionally not exported. The test only needs to confirm the
// returned error has a message.
var errMissingDepsSentinel = errors.New("sentinel")
// TestTerminalSandboxStatus pins the contract that decides whether a
// previously created sandbox game gets purged on the next boot.
// Terminal states are deleted (cascade-style) so the developer's
// lobby never piles up dead tiles between `make rebuild` cycles.
func TestTerminalSandboxStatus(t *testing.T) {
terminal := []string{"cancelled", "finished", "start_failed"}
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
for _, status := range terminal {
if !terminalSandboxStatus(status) {
t.Errorf("expected %q to be terminal", status)
}
}
for _, status := range live {
if terminalSandboxStatus(status) {
t.Errorf("expected %q to be non-terminal", status)
}
}
}
type stubEnsurer struct{}
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
return uuid.UUID{}, nil
}
+4 -5
View File
@@ -274,11 +274,10 @@ func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time)
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
// Idempotent: returns nil when no game matches.
//
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
// terminal "Dev Sandbox" tile from a previous local-dev session can
// be scrubbed before a fresh game spawns. Production callers must
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
// destructive and bypasses the cascade-notification machinery.
// `DeleteGame` is destructive — a hard delete that bypasses the
// cascade-notification machinery — so production callers stay on the
// regular cancel / finish lifecycle. It is exercised by the lobby
// integration tests.
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
return err
+2 -2
View File
@@ -248,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
}
}
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
// bootstrap relies on: removing a game wipes every referencing row
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
// removing a game wipes every referencing row
// (memberships, applications, invites, runtime_records,
// player_mappings) in a single SQL statement. Before this is wired
// the developer's lobby pile up cancelled tiles between
+5 -6
View File
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
// writes as ApproveApplication: the per-game race-name reservation
// row plus the membership row, and refreshes the in-memory caches.
//
// The method is intended for boot-time provisioning by
// `backend/internal/devsandbox` and similar trusted callers. It is
// not exposed through any HTTP handler. The caller must guarantee
// The method is intended for trusted boot-time provisioning and
// integration tests; it is not exposed through any HTTP handler. The
// caller must guarantee
// game.Status == GameStatusEnrollmentOpen — the function returns
// ErrConflict otherwise — and that the race-name policy and
// canonical-key invariants are honoured (the implementation reuses
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
// or unsuitable name still fails).
//
// Idempotency: if a membership for (GameID, UserID) already exists
// the function returns the existing row without modifying state.
// This makes the helper safe to call on every backend boot from
// devsandbox.Bootstrap.
// the function returns the existing row without modifying state, so
// the helper is safe to call repeatedly.
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
displayName, err := ValidateDisplayName(in.RaceName)
if err != nil {
+2 -3
View File
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
// referencing table (memberships / applications / invites /
// runtime_records / player_mappings — all declared with ON DELETE
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
// matches. Used by the dev-sandbox bootstrap to scrub terminal
// games on every backend boot so the developer's lobby never piles
// up cancelled tiles.
// matches. A hard delete for trusted callers and integration tests;
// production lifecycle uses cancel / finish.
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
g := table.Games
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
+139
View File
@@ -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 <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 <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
}
@@ -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
}
@@ -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)
}
@@ -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 <input type="datetime-local">
// (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()
}
@@ -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)
}
}
@@ -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()
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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())
}
})
}
}
@@ -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)
}
@@ -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)
}
}
+59
View File
@@ -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
+45 -1
View File
@@ -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
@@ -857,6 +888,19 @@ addition.
- Health probes are unauthenticated `GET /healthz` (process liveness) and
`GET /readyz` (Postgres reachable, migrations applied, gRPC listener
bound). Probes are excluded from anti-replay and rate limiting.
- **Collection (dev, production mirror).** The long-lived dev environment
(`tools/dev-deploy/`) runs a full metrics + logs + traces stack on its
internal network with no host ports: Prometheus scrapes the backend
(`:9100`) and gateway (`:9191`) endpoints plus `node-exporter` and
cAdvisor; Tempo ingests OTLP traces from backend and gateway; Loki
stores container logs shipped by promtail (Docker service-discovery on
the `galaxy.stack=dev-deploy` label). Grafana (provisioned datasources
+ dashboards) and the Mailpit capture UI are reached only through the
operator console's single `/_gm` Basic Auth gate (§14.1) — at
`/_gm/grafana/` and `/_gm/mailpit/` — so one password covers the
console and both UIs. Retention is tuned small (Prometheus 15d, Loki
7d, Tempo 3d). The same compose fragment is meant to back production.
See `tools/dev-deploy/monitoring/README.md`.
## 18. CI and Environments
+25
View File
@@ -1162,6 +1162,31 @@ 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. The console nav also links to Grafana
(metrics, logs and traces) and the Mailpit capture UI, which the
deployment serves under the same `/_gm` Basic Auth gate — one sign-in
covers the console and both UIs.
### 10.3 Admin account management
Existing admins can list other admins, create new ones, look up a
+25
View File
@@ -1197,6 +1197,31 @@ 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-срез на текущий момент для быстрой диагностики, не история
метрик. Навигация консоли также ведёт в Grafana (метрики, логи и
трейсы) и в UI захвата почты Mailpit, которые деплой отдаёт под тем же
шлюзом Basic Auth `/_gm` — один вход покрывает консоль и оба UI.
### 10.3 Управление admin-аккаунтами
Существующие админы могут перечислять других админов, создавать
+24
View File
@@ -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.
+9
View File
@@ -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()
+52
View File
@@ -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
}
+49
View File
@@ -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
}
@@ -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("<h1>Dashboard</h1>"))
}))
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)
}
@@ -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":
+21
View File
@@ -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 != "" {
+30
View File
@@ -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"),
+63
View File
@@ -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)
}
}
-6
View File
@@ -7,12 +7,6 @@
# baked into `docker-compose.yml`, so this file documents the knobs
# rather than driving them.
# Auto-provisioned sandbox bootstrap. Empty disables the bootstrap.
BACKEND_DEV_SANDBOX_EMAIL=dev@galaxy.lan
BACKEND_DEV_SANDBOX_ENGINE_IMAGE=galaxy-engine:dev
BACKEND_DEV_SANDBOX_ENGINE_VERSION=0.1.0
BACKEND_DEV_SANDBOX_PLAYER_COUNT=20
# `123456` short-circuits the email-code path for the dev account.
# This is also the docker-compose default — set the variable to an
# empty string here when the environment must rely on real Mailpit
+30
View File
@@ -29,6 +29,36 @@
reverse_proxy galaxy-api:8080
}
# Operator console + observability behind one Basic Auth gate. The gate
# credential equals the admin-console account (dev: gm / gm-dev-password),
# so Caddy forwards the same Authorization header to the backend `/_gm`
# surface (its own Basic Auth) and to Grafana/Mailpit — one prompt covers
# all three. The gateway applies the admin anti-abuse class to the console.
@gm path /_gm /_gm/*
handle @gm {
basic_auth {
gm "$2a$14$xVh1TLaZxh8fazlKrI9Mx.NQMQlMarYWtr3FRELmZIXuac/DeeTRO"
}
# Grafana under /_gm/grafana/ (sub-path mode; anonymous Admin, so the
# /_gm gate is the only barrier — GF_AUTH_BASIC_ENABLED=false makes it
# ignore the forwarded Authorization header).
handle /_gm/grafana/* {
reverse_proxy galaxy-grafana:3000
}
# Mailpit captured-mail UI under /_gm/mailpit/ (MP_WEBROOT). Shows
# every message the backend sent, relayed or not.
handle /_gm/mailpit/* {
reverse_proxy galaxy-mailpit:8025
}
# The operator console itself (gateway -> backend /_gm surface).
handle {
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 {
+3 -159
View File
@@ -1,164 +1,8 @@
# `tools/dev-deploy/` — known issues
Issues that surface in the long-lived dev environment but are not yet
fixed. Each entry lists the observed symptom, the diagnostic evidence,
the working hypothesis, and the open questions that have to be
answered before a fix lands.
## Dev Sandbox game flips to `cancelled` after a `dev-deploy` redispatch
### Symptom
A previously `running` "Dev Sandbox" game (created by
`backend/internal/devsandbox`) transitions to `cancelled` ~15 minutes
after a `dev-deploy.yaml` workflow_dispatch run finishes. The user's
browser session survives (the same `device_session_id` keeps working),
but the lobby shows no game because the only game it had is now
terminal. `purgeTerminalSandboxGames` does pick it up on the **next**
boot and creates a fresh sandbox — but the first redispatch leaves
the user with an empty lobby until backend restarts again.
### Diagnostic evidence
Backend logs from the broken cycle (timestamps abbreviated):
```text
20:24:40 dev_sandbox: purged terminal sandbox game game_id=<prev> status=cancelled
20:24:40 dev_sandbox: memberships ensured count=20 game_id=<new>
20:24:40 dev_sandbox: bootstrap complete user_id=<owner> game_id=<new> status=starting
...
20:25:09 user mail sent failed (diplomail tables missing — unrelated)
...
20:39:40 lobby: game cancelled by runtime reconciler game_id=<new>
op=reconcile status=removed message="container disappeared"
```
Between 20:24:40 (`status=starting`) and 20:39:40 (reconciler cancel)
the backend logs are silent on the runtime / engine paths — no
`engine spawned`, no `engine container started`, no `runtime
transition` lines. The reconciler then fires and reports the engine
container as missing.
`docker ps -a --filter 'label=org.opencontainers.image.title=galaxy-game-engine'`
returns no rows during this window — the engine container is neither
running nor stopped on the host, so it either was never spawned or
was removed before the host snapshot.
### What has been ruled out
A live `docker inspect` on a healthy engine container shows:
```text
Labels: galaxy.backend=1, galaxy.engine_version=0.1.0,
galaxy.game_id=<uuid>,
org.opencontainers.image.title=galaxy-game-engine,
com.galaxy.{cpu_quota,memory,pids_limit}
AutoRemove: false
RestartPolicy: on-failure
NetworkMode: galaxy-dev-internal
```
There are no `com.docker.compose.*` labels and `AutoRemove=false`,
so `--remove-orphans` cannot reap the engine and a `--rm`-style
self-destruct is not in play. Two redispatches captured under
`docker events --filter event=create,start,die,destroy,kill,stop`
also confirmed it: across both runs the only `die` / `destroy`
events were for `galaxy-dev-{backend,api,caddy}`. The live engine
container survived both redispatches, and the reconciler that
fires 60 seconds after the new backend boots correctly matched
it through `byGameID` / `byContainerID`.
`backend/internal/runtime/service.go` only removes engine
containers from the explicit `runStop` / `runRestart` / `runPatch`
paths. There is no `runtime.Service.Shutdown` that proactively
kills containers on backend exit, so a graceful SIGTERM to
`galaxy-dev-backend` will not touch its child engine containers.
### Host-side hypotheses considered and rejected by the owner
The natural follow-up suspects after compose was cleared — host-side
`docker prune` cron jobs, a manual `docker rm`, an out-of-band
`dockerd` restart, and an idle-state engine crash — were all
rejected by the project owner: the dev host runs none of those
periodic cleanups, no one manually removed the container, dockerd
was not restarted in the window, and the engine binary does not
crash while idling on API calls.
### Best remaining suspicion
Something the `dev-deploy.yaml` CI run does between successful
image builds and the final `docker compose up -d --wait
--remove-orphans` clobbers the previously-spawned engine container.
The chain at runtime contains:
1. `docker build -t galaxy-engine:dev -f game/Dockerfile .`
2. `docker compose build galaxy-backend galaxy-api`
3. `docker run --rm` alpine for the UI volume seed
4. `docker compose up -d --wait --remove-orphans`
None of these *should* touch an unmanaged engine container, but
the reproduction window points squarely inside this sequence. A
deliberate next reproduction with `docker events --since 0` armed
*before* the deploy starts and live for the entire job — captured
end-to-end on the dev host, not just the chunk after backend
recreate — would pin which step emits the `destroy` on the engine.
### Update 2026-05-19: integration preclean identified as one cause
A live reproduction during the post-merge auto-deploy cycle (Gitea
run #188 dev-deploy plus parallel run #190 integration) pinned one
clobbering source: `integration/scripts/preclean.sh` was unscoped
and removed *every* container labelled `galaxy.backend=1`, including
the dev-deploy engine. Timeline from the dev host:
```text
23:10:40 backend pre-bootstrap reconciler tick: engine alive
23:10:40 dev_sandbox bootstrap: status=running
23:10:56 preclean: removing 1 backend-managed engine containers ← integration run #190
23:11:40 reconciler: container disappeared → game cancelled
```
Fix landed: `BACKEND_STACK_LABEL=integration` is now passed to
every integration backend (see
`integration/testenv/backend.go`) and `preclean.sh` AND-combines
`galaxy.backend=1` with `galaxy.stack=integration`, so dev-deploy /
local-dev engines stamped with different stack values are no longer
collateral.
This covers **push**-triggered cycles where `dev-deploy.yaml` and
`integration.yaml` run on the same Gitea host. The original
hypothesis (a `workflow_dispatch dev-deploy` solo run also losing
the engine) is *not* explained by the integration fix — manual
dispatches do not trigger `integration.yaml`. Keep this entry open
until a solo-dispatch reproduction confirms whether the symptom
still occurs.
### Status
Partially fixed (push-triggered cycles). Solo `workflow_dispatch`
reproductions still open. If the symptom recurs after the
integration fix lands, capture `docker events --since 0` for the
full dispatch window and attach here.
### Workaround in use today
When the sandbox game flips to `cancelled`, redispatch `dev-deploy`:
```sh
curl -X POST -n -H 'Content-Type: application/json' \
-d '{"ref":"<branch>"}' \
https://gitea.iliadenisov.ru/api/v1/repos/developer/galaxy-game/actions/workflows/dev-deploy.yaml/dispatches
```
The next boot's `purgeTerminalSandboxGames` removes the cancelled
row, `findOrCreateSandboxGame` creates a fresh one, and
`ensureMembershipsAndDrive` puts the new game back to `running`.
### Owner
Unassigned. File an issue once we have the runtime / reconciler
analysis above; reference this section in the issue body so future
redeploys can short-circuit the diagnostic loop.
Issues that surfaced in the long-lived dev environment. Each entry lists
the observed symptom, the diagnostic evidence, and the fix or the open
questions that have to be answered before a fix lands.
## `docker restart galaxy-dev-backend` fails after the CI runner cleans up
+79 -19
View File
@@ -114,17 +114,72 @@ calls `make clean-data`.
The same dev-mode email-code override as `tools/local-dev/` applies,
and the dev-deploy compose ships with it enabled by default:
1. Enter `dev@galaxy.lan` (or whatever `BACKEND_DEV_SANDBOX_EMAIL`
resolves to) in the login form.
1. Enter your email address in the login form.
2. Submit `123456` as the code — the docker-compose default for
`BACKEND_AUTH_DEV_FIXED_CODE` is `123456`, so the bcrypt-hashed
email code stays a fallback. To force real Mailpit codes (e.g. for
mail-flow QA), set `BACKEND_AUTH_DEV_FIXED_CODE=` (empty) in a
local `.env` and `make rebuild`.
email code stays a fallback. To force the real email code (which
Mailpit then relays to your Gmail — see **Mail** below), set
`BACKEND_AUTH_DEV_FIXED_CODE=` (empty) and redeploy.
The fixed-code override is rejected by production env loaders, so it
cannot leak into the prod environment.
## Mail
The backend always submits mail to **Mailpit** (`galaxy-mailpit:1025`),
exactly as it would to a production SMTP server. Mailpit captures every
message in its UI (internal `:8025`) and, when configured, **relays**
the ones whose recipient matches `GALAXY_DEV_MAIL_RELAY_MATCH` up to a
real Gmail account — so an OTP addressed to you lands in your real inbox
while everything else stays captured-only.
Configure the relay through Gitea Actions secrets/vars (never
committed); the `dev-deploy.yaml` workflow renders Mailpit's
`relay.conf` (from `tools/dev-deploy/mailpit/relay.conf.tmpl`) and seeds
it into the `galaxy-dev-mailpit-config` volume:
| Name | Kind | Purpose |
| --- | --- | --- |
| `GALAXY_DEV_MAIL_RELAY_USERNAME` | secret | Gmail address used as the relay login + From. |
| `GALAXY_DEV_MAIL_RELAY_PASSWORD` | secret | Gmail **App Password** (requires 2FA; not the account password). |
| `GALAXY_DEV_MAIL_RELAY_MATCH` | var | Recipient regex to auto-relay (e.g. your Gmail address). Unset → capture-only. |
With none set the stack only captures mail (the compose relay-match
defaults to a non-routable address), so it can never email third
parties.
The capture UI is exposed through the operator console's `/_gm` gate at
[`/_gm/mailpit/`](https://galaxy.lan/_gm/mailpit/) — one Basic Auth for
the console, Grafana and Mailpit (see **Observability**). It shows
**every** message the backend sent, relayed or not, so you can read any
account's OTP regardless of the relay-match. For multi-account testing:
register several `you+tag@gmail.com` aliases and widen the match to a
regex such as `^you(\+[^@]+)?@gmail\.com$` (Gmail folds every `+tag`
into one inbox), or just read the codes in the Mailpit UI, or skip mail
entirely with the `123456` dev-code.
## Observability
A full metrics + logs + traces stack runs alongside the app on the
internal network (no host ports), as a production mirror. **Grafana**
and the **Mailpit** UI are reached only through the operator console's
single `/_gm` Basic Auth gate — one password (the admin-console account)
unlocks the console, [`/_gm/grafana/`](https://galaxy.lan/_gm/grafana/)
and [`/_gm/mailpit/`](https://galaxy.lan/_gm/mailpit/), with links in the
console nav. Grafana runs anonymous-Admin behind the gate (no own
login); Prometheus, Loki and Tempo stay internal-only.
- **Metrics** — Prometheus scrapes backend, gateway, `node-exporter` and
cAdvisor.
- **Logs** — promtail → Loki (Docker SD on the `galaxy.stack=dev-deploy`
label).
- **Traces** — backend + gateway → Tempo over OTLP.
Grafana's admin user is seeded from `GALAXY_DEV_GRAFANA_ADMIN_PASSWORD`
(for provisioning/API; the UI needs no Grafana login). See
[`monitoring/README.md`](monitoring/README.md) for services, configs and
tuning knobs.
## Networking
```
@@ -139,6 +194,8 @@ galaxy-caddy (networks: edge + galaxy-dev-internal)
│ /game/* -> file_server /srv/galaxy-ui (volume galaxy-dev-ui-dist)
│ /api/*, /healthz -> reverse_proxy galaxy-api:8080
│ /rpc/* -> reverse_proxy galaxy-api:9090 (strips /rpc)
│ /_gm, /_gm/* -> reverse_proxy galaxy-api:8080 (Basic Auth gate;
│ /_gm/grafana/ -> grafana, /_gm/mailpit/ -> mailpit)
galaxy-dev-internal
├─ galaxy-api (gateway: :8080 REST, :9090 gRPC)
@@ -146,7 +203,9 @@ galaxy-dev-internal
├─ galaxy-postgres (postgres: :5432)
├─ galaxy-redis (redis: :6379)
├─ galaxy-mailpit (mailpit: :8025 UI, :1025 SMTP)
─ engine containers (spawned by backend on demand)
─ engine containers (spawned by backend on demand)
└─ observability (prometheus, grafana, loki, promtail, tempo,
node-exporter, cadvisor)
```
The compose project deliberately exposes no host ports. Diagnostics
@@ -191,8 +250,10 @@ make clean-data Stop everything and wipe volumes + game-state dir
## Files
- `docker-compose.yml` — six services: postgres, redis, mailpit,
galaxy-backend, galaxy-api, galaxy-caddy. `galaxy-caddy` mounts both
- `docker-compose.yml` — the application services (postgres, redis,
mailpit, galaxy-backend, galaxy-api, galaxy-caddy) plus the
observability stack (prometheus, grafana, loki, promtail, tempo,
node-exporter, cadvisor). `galaxy-caddy` mounts both
the `galaxy-dev-site-dist` (`/srv/galaxy-site`) and
`galaxy-dev-ui-dist` (`/srv/galaxy-ui`) volumes and reverse-proxies
both gateway tiers (REST/health on `:8080`, Connect/gRPC-web on
@@ -204,6 +265,8 @@ make clean-data Stop everything and wipe volumes + game-state dir
at `/etc/caddy/Caddyfile`.
- `Caddyfile.prod` — placeholder for a future prod deployment; not used
by this compose.
- `monitoring/` — Prometheus / Loki / promtail / Tempo / Grafana
configuration, provisioned as code; see `monitoring/README.md`.
- `Makefile` — wrapper over `docker compose` with helpers for engine,
site/UI seeding, health probes, and full wipe.
- `.env.example` — non-secret defaults for the compose `${VAR:-}`
@@ -212,8 +275,7 @@ make clean-data Stop everything and wipe volumes + game-state dir
## Known issues
See [`KNOWN-ISSUES.md`](KNOWN-ISSUES.md) for symptoms that surface
in the long-lived dev environment but are not yet fixed (currently:
the sandbox game flipping to `cancelled` after a redispatch).
in the long-lived dev environment but are not yet fixed.
## Deployment cadence
@@ -237,12 +299,12 @@ behind. There is no separate state to clean up between the two paths.
### Engine image drift recycle
`backend` spawns one engine container per game (the long-lived "Dev
Sandbox" plus any user-created games) and the reconciler reattaches
to whatever it finds with the `galaxy.stack=dev-deploy` label. That
reattach does not check the running container's image SHA against the
freshly-built `galaxy-engine:dev` tag, so an unchanged container would
otherwise keep serving the previous engine code after a redeploy.
`backend` spawns one engine container per running game and the
reconciler reattaches to whatever it finds with the
`galaxy.stack=dev-deploy` label. That reattach does not check the
running container's image SHA against the freshly-built
`galaxy-engine:dev` tag, so an unchanged container would otherwise
keep serving the previous engine code after a redeploy.
The `dev-deploy.yaml` workflow handles this in the
`Recycle engine containers on image drift` step. When `docker build`
@@ -250,9 +312,7 @@ produces a new `galaxy-engine:dev` SHA, the step compares it against
every running `galaxy-game-*` container and, for each drifted one,
stops the backend, removes the container, wipes its bind-mounted
state directory (Engine.Init() writes turn-0 over any pre-existing
`turn-N` files), and cascade-deletes the lobby `games` row. The
`dev-sandbox` bootstrap on the next backend boot finds no live
sandbox and provisions a fresh one on the new engine image.
`turn-N` files), and cascade-deletes the lobby `games` row.
When the engine sources are unchanged, the BuildKit cache hits and
the SHA stays the same — the recycle step is a no-op and the running
+214 -12
View File
@@ -66,12 +66,26 @@ services:
image: axllent/mailpit:v1.21
container_name: galaxy-dev-mailpit
restart: unless-stopped
# Mailpit is both the SMTP submission point and a relay: it captures
# every message in its UI and auto-relays the ones whose recipient
# matches GALAXY_DEV_MAIL_RELAY_MATCH to the Gmail account in the
# secret-rendered relay config. The default match is non-routable, so
# a stack brought up without the relay secret only captures, never sends.
command:
- "--smtp-relay-config=/etc/mailpit/relay.conf"
- "--smtp-relay-matching=${GALAXY_DEV_MAIL_RELAY_MATCH:-nobody@invalid.example}"
# Serve the capture UI under /_gm/mailpit so the host Caddy can expose
# it at https://galaxy.lan/_gm/mailpit/ behind the shared /_gm gate;
# SMTP is unaffected.
- "--webroot=/_gm/mailpit"
labels:
galaxy.stack: dev-deploy
networks:
- galaxy-internal
volumes:
- galaxy-dev-mailpit-config:/etc/mailpit:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/livez"]
test: ["CMD", "wget", "-q", "-O-", "http://localhost:8025/_gm/mailpit/livez"]
interval: 3s
timeout: 3s
retries: 30
@@ -108,23 +122,31 @@ services:
BACKEND_NOTIFICATION_ADMIN_EMAIL: admin@galaxy.lan
BACKEND_MAIL_WORKER_INTERVAL: 500ms
BACKEND_NOTIFICATION_WORKER_INTERVAL: 500ms
BACKEND_OTEL_TRACES_EXPORTER: none
BACKEND_OTEL_METRICS_EXPORTER: none
BACKEND_OTEL_TRACES_EXPORTER: otlp
BACKEND_OTEL_PROTOCOL: grpc
BACKEND_OTEL_ENDPOINT: "galaxy-tempo:4317"
# Tempo's OTLP receiver is plaintext on the internal network; the
# backend's gRPC exporter defaults to TLS, so disable it via the
# standard SDK env (applied on top of WithEndpoint).
OTEL_EXPORTER_OTLP_INSECURE: "true"
# 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
# bcrypt-hashed code is single-use). Set the var to an empty
# string in `.env` to disable.
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-123456}
# Long-lived dev environment always bootstraps the "Dev Sandbox"
# game owned by this email so a freshly redeployed stack already
# has one ready-to-play game in the lobby. Set the variable to an
# empty string in `.env` to disable the bootstrap (e.g. for a
# cold-start QA pass).
BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-dev@galaxy.lan}
BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-galaxy-engine:dev}
BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-0.1.0}
BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-20}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Per-game state directories live under the same absolute path
@@ -180,6 +202,16 @@ 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"
# Traces -> Tempo over OTLP gRPC (plaintext on the internal net).
OTEL_SERVICE_NAME: galaxy-gateway
OTEL_TRACES_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: grpc
OTEL_EXPORTER_OTLP_ENDPOINT: "http://galaxy-tempo:4317"
OTEL_EXPORTER_OTLP_INSECURE: "true"
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 +240,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"
@@ -245,6 +280,163 @@ services:
- galaxy-internal
- edge
galaxy-prometheus:
image: prom/prometheus:v2.55.1
container_name: galaxy-dev-prometheus
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- galaxy-dev-prometheus-data:/prometheus
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 384m
galaxy-loki:
image: grafana/loki:3.3.2
container_name: galaxy-dev-loki
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command: ["-config.file=/etc/loki/loki.yml"]
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/loki/loki.yml:/etc/loki/loki.yml:ro
- galaxy-dev-loki-data:/loki
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 384m
galaxy-promtail:
image: grafana/promtail:3.3.2
container_name: galaxy-dev-promtail
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command: ["-config.file=/etc/promtail/promtail.yml"]
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/promtail/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 128m
galaxy-tempo:
image: grafana/tempo:2.7.1
container_name: galaxy-dev-tempo
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command: ["-config.file=/etc/tempo/tempo.yml"]
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/tempo/tempo.yml:/etc/tempo/tempo.yml:ro
- galaxy-dev-tempo-data:/var/tempo
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 384m
galaxy-node-exporter:
image: prom/node-exporter:v1.8.2
container_name: galaxy-dev-node-exporter
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/rootfs
- --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
pid: host
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 64m
galaxy-cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.49.1
container_name: galaxy-dev-cadvisor
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
command:
- --housekeeping_interval=30s
- --docker_only=true
- --store_container_labels=false
privileged: true
devices:
- /dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 256m
galaxy-grafana:
image: grafana/grafana:11.4.0
container_name: galaxy-dev-grafana
restart: unless-stopped
labels:
galaxy.stack: dev-deploy
depends_on:
- galaxy-prometheus
- galaxy-loki
- galaxy-tempo
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GALAXY_DEV_GRAFANA_ADMIN_PASSWORD:-admin}
GF_SERVER_ROOT_URL: https://galaxy.lan/_gm/grafana/
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
# No own login: the /_gm Basic Auth gate is the only barrier, so
# serve everyone as anonymous Admin and ignore the forwarded
# Authorization header (basic auth off, login form off).
GF_AUTH_ANONYMOUS_ENABLED: "true"
GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
GF_AUTH_DISABLE_LOGIN_FORM: "true"
GF_AUTH_BASIC_ENABLED: "false"
GF_USERS_ALLOW_SIGN_UP: "false"
GF_ANALYTICS_REPORTING_ENABLED: "false"
GF_ANALYTICS_CHECK_FOR_UPDATES: "false"
GF_NEWS_NEWS_FEED_ENABLED: "false"
volumes:
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/grafana/provisioning:/etc/grafana/provisioning:ro
- ${GALAXY_DEV_MONITORING_DIR:-./monitoring}/grafana/dashboards:/var/lib/grafana/dashboards:ro
- galaxy-dev-grafana-data:/var/lib/grafana
networks:
- galaxy-internal
deploy:
resources:
limits:
memory: 256m
networks:
galaxy-internal:
name: galaxy-dev-internal
@@ -274,3 +466,13 @@ volumes:
name: galaxy-dev-site-dist
galaxy-dev-geoip-data:
name: galaxy-dev-geoip-data
galaxy-dev-mailpit-config:
name: galaxy-dev-mailpit-config
galaxy-dev-prometheus-data:
name: galaxy-dev-prometheus-data
galaxy-dev-grafana-data:
name: galaxy-dev-grafana-data
galaxy-dev-loki-data:
name: galaxy-dev-loki-data
galaxy-dev-tempo-data:
name: galaxy-dev-tempo-data
+18
View File
@@ -0,0 +1,18 @@
# Mailpit SMTP relay upstream — RENDERED AT DEPLOY TIME by
# .gitea/workflows/dev-deploy.yaml from Gitea Actions secrets, then
# seeded into the `galaxy-dev-mailpit-config` volume. The Gmail App
# Password is a secret and MUST NOT be committed: this template only
# carries ${PLACEHOLDER}s that the workflow substitutes. See
# tools/dev-deploy/README.md ("Mail").
#
# Mailpit captures every message; the `--smtp-relay-matching` flag (set
# from GALAXY_DEV_MAIL_RELAY_MATCH in the compose) decides which
# recipients are actually relayed up to this Gmail account.
host: smtp.gmail.com
port: 587
starttls: true
allow-insecure: false
auth: login
username: ${GALAXY_DEV_MAIL_RELAY_USERNAME}
password: ${GALAXY_DEV_MAIL_RELAY_PASSWORD}
return-path: ${GALAXY_DEV_MAIL_RELAY_USERNAME}
+77
View File
@@ -0,0 +1,77 @@
# `tools/dev-deploy/monitoring/` — observability stack
The long-lived dev environment runs a full metrics + logs + traces stack
alongside the application as a **production mirror**: the same compose
fragment and collector configs are meant to back production later. Every
collector lives on the internal `galaxy-dev-internal` network and
publishes **no host port**. The browser-reachable pieces (Grafana and
the Mailpit UI) sit behind the operator console's single `/_gm` Basic
Auth gate — see [`../README.md`](../README.md) and `ARCHITECTURE.md §14`.
## Services
| Service | Image | Role | Reachable |
| --- | --- | --- | --- |
| `galaxy-prometheus` | `prom/prometheus` | Scrape + store metrics (15d) | internal `:9090` |
| `galaxy-loki` | `grafana/loki` | Log store (7d) | internal `:3100` |
| `galaxy-promtail` | `grafana/promtail` | Ship container logs to Loki | — |
| `galaxy-tempo` | `grafana/tempo` | Trace store (3d), OTLP receiver | internal `:3200`, OTLP `:4317`/`:4318` |
| `galaxy-node-exporter` | `prom/node-exporter` | Host metrics | internal `:9100` |
| `galaxy-cadvisor` | `cadvisor` | Per-container CPU/memory/IO | internal `:8080` |
| `galaxy-grafana` | `grafana/grafana` | Dashboards + Explore | Caddy `/_gm/grafana/` |
## What is collected
- **Metrics.** Prometheus (30s interval) scrapes the backend Prometheus
endpoint (`galaxy-backend:9100`), the gateway admin endpoint
(`galaxy-api:9191`), `node-exporter` (host) and cAdvisor (per
container). Engine containers expose no `/metrics`; cAdvisor covers
their resource use.
- **Logs.** promtail discovers containers through the Docker API,
filtered to the `galaxy.stack=dev-deploy` label, and ships their
stdout/stderr to Loki labelled by `container`.
- **Traces.** backend and gateway export OTLP traces over gRPC to Tempo
(`galaxy-tempo:4317`), plaintext on the internal network
(`OTEL_EXPORTER_OTLP_INSECURE=true`, since Tempo's receiver is not
TLS-wrapped inside the contour).
## Grafana access (behind the `/_gm` gate)
Grafana is served under `/_gm/grafana/` (`GF_SERVER_ROOT_URL` +
`GF_SERVER_SERVE_FROM_SUB_PATH=true`) **behind the shared operator gate**:
the Caddy `/_gm/*` Basic Auth (the admin-console account) is the only
barrier. Grafana itself runs as **anonymous Admin** with its login form
and basic auth disabled (`GF_AUTH_ANONYMOUS_ENABLED=true`,
`GF_AUTH_ANONYMOUS_ORG_ROLE=Admin`, `GF_AUTH_DISABLE_LOGIN_FORM=true`,
`GF_AUTH_BASIC_ENABLED=false`), so it ignores the forwarded credentials
and asks for no second password. `GALAXY_DEV_GRAFANA_ADMIN_PASSWORD`
still seeds the admin user for provisioning/API use.
Datasources (Prometheus, Loki, Tempo) and a starter dashboard
(`grafana/dashboards/galaxy-overview.json`) are provisioned as code under
`grafana/provisioning/`.
## Config delivery
`dev-deploy.yaml` copies this directory to a stable host path
(`$HOME/.galaxy-dev/monitoring`, exported as `GALAXY_DEV_MONITORING_DIR`)
before `compose up`, and the compose binds it read-only into the
collectors. A stable path — not the ephemeral CI workspace — keeps the
mounts valid across container restarts and host reboots (the same lesson
as the geoip volume; see `../KNOWN-ISSUES.md`).
## Tuning (cost knobs)
Defaults favour the smallest workable footprint; all are config/compose
values:
- Prometheus `scrape_interval=30s`, `--storage.tsdb.retention.time=15d`.
- Loki `retention_period=168h` (7d); Tempo `block_retention=72h` (3d).
- cAdvisor `--housekeeping_interval=30s`.
- Per-service `deploy.resources.limits.memory` caps (~1.5 GB total cap;
steady-state well under that).
Seven always-on containers cost roughly ~1.1 GB steady RAM and
~1.52.5 GB disk at these retention windows. cAdvisor is the main CPU
cost; on a constrained host it can be dropped (host + app metrics still
cover most needs).
@@ -0,0 +1,46 @@
{
"annotations": { "list": [] },
"editable": true,
"graphTooltip": 0,
"panels": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "reqps" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"id": 1,
"title": "Backend HTTP request rate",
"type": "timeseries",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum by (group) (rate(http_requests_total[5m]))",
"legendFormat": "{{group}}"
}
]
},
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"id": 2,
"title": "Container memory (cadvisor)",
"type": "timeseries",
"targets": [
{
"datasource": { "type": "prometheus", "uid": "prometheus" },
"expr": "sum by (name) (container_memory_usage_bytes{name=~\"galaxy-dev-.*|galaxy-game-.*\"})",
"legendFormat": "{{name}}"
}
]
}
],
"schemaVersion": 39,
"tags": ["galaxy"],
"templating": { "list": [] },
"time": { "from": "now-6h", "to": "now" },
"timepicker": {},
"title": "Galaxy — overview",
"uid": "galaxy-overview",
"version": 1,
"weekStart": ""
}
@@ -0,0 +1,12 @@
# Grafana dashboard provider: load every JSON under the mounted
# dashboards directory at startup (provisioned as code).
apiVersion: 1
providers:
- name: galaxy
type: file
disableDeletion: false
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: false
@@ -0,0 +1,22 @@
# Grafana datasources provisioned as code (dev↔prod parity). All reach
# the collectors by Docker DNS (compose service names) on
# galaxy-dev-internal.
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
uid: prometheus
url: http://galaxy-prometheus:9090
isDefault: true
- name: Loki
type: loki
access: proxy
uid: loki
url: http://galaxy-loki:3100
- name: Tempo
type: tempo
access: proxy
uid: tempo
url: http://galaxy-tempo:3200
+47
View File
@@ -0,0 +1,47 @@
# Single-binary Loki for the dev stack: filesystem storage, in-memory
# ring, 7-day retention. Internal-only (no host port).
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9095
log_level: warn
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2024-01-01
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: 168h
reject_old_samples: true
reject_old_samples_max_age: 168h
compactor:
working_directory: /loki/compactor
retention_enabled: true
delete_request_store: filesystem
query_range:
results_cache:
cache:
embedded_cache:
enabled: true
max_size_mb: 64
@@ -0,0 +1,24 @@
# Prometheus scrape config for the dev observability stack. Retention is
# a CLI flag in the compose command, not here. Targets are reached by
# Docker DNS (compose service names) on galaxy-dev-internal; nothing is
# published to the host.
global:
scrape_interval: 30s
evaluation_interval: 30s
scrape_configs:
- job_name: backend
static_configs:
- targets: ["galaxy-backend:9100"]
- job_name: gateway
static_configs:
- targets: ["galaxy-api:9191"]
- job_name: node
static_configs:
- targets: ["galaxy-node-exporter:9100"]
- job_name: cadvisor
static_configs:
- targets: ["galaxy-cadvisor:8080"]
- job_name: prometheus
static_configs:
- targets: ["localhost:9090"]
@@ -0,0 +1,30 @@
# Promtail tails the dev stack's container logs via the Docker API
# (service discovery filtered to the galaxy.stack=dev-deploy label) and
# ships them to Loki. Requires the Docker socket mounted read-only.
server:
http_listen_port: 9080
grpc_listen_port: 0
log_level: warn
positions:
filename: /tmp/positions.yaml
clients:
- url: http://galaxy-loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 15s
filters:
- name: label
values: ["galaxy.stack=dev-deploy"]
relabel_configs:
- source_labels: ["__meta_docker_container_name"]
regex: "/?(.*)"
target_label: container
- source_labels: ["__meta_docker_container_label_galaxy_game_id"]
target_label: game_id
- source_labels: ["__meta_docker_container_log_stream"]
target_label: stream
@@ -0,0 +1,30 @@
# Single-binary Tempo for the dev stack: OTLP receivers, local block
# storage, 3-day retention. Internal-only (no host port). Backend and
# gateway push traces here over OTLP gRPC (4317).
server:
http_listen_port: 3200
log_level: warn
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 72h
storage:
trace:
backend: local
local:
path: /var/tempo/blocks
wal:
path: /var/tempo/wal
+7 -9
View File
@@ -22,7 +22,7 @@ help:
@echo " make up Build (if needed) and bring up the stack, wait until healthy"
@echo " make down Stop compose containers, leave engines + volumes intact"
@echo " make rebuild Force rebuild of backend / gateway images and bring up"
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by the dev sandbox"
@echo " make build-engine Build the engine image $(ENGINE_IMAGE) used by running games"
@echo " make stop-engines Stop and remove only the per-game engine containers"
@echo " make prune-broken-engines Remove non-running engine containers Docker can't heal (run inside 'up')"
@echo " make clean Stop everything (incl. engines) and wipe volumes + game state"
@@ -37,8 +37,9 @@ help:
@echo " pnpm -C ui/frontend dev"
@echo "and open http://localhost:5173 (UI) plus http://localhost:8025 (Mailpit)."
@echo ""
@echo "Default login for the auto-provisioned dev sandbox: dev@local.test"
@echo "(see BACKEND_DEV_SANDBOX_EMAIL in .env). Login code: 123456."
@echo "Sign in with email-OTP; the fixed login code 123456 works when"
@echo "BACKEND_AUTH_DEV_FIXED_CODE is set in .env. No game is auto-provisioned —"
@echo "load a legacy report via the UI's DEV report loader to exercise the map."
up: build-engine prune-broken-engines
$(COMPOSE) up -d --wait
@@ -88,12 +89,9 @@ stop-engines:
# bind-mount source and leaves it stuck in `exited` / `created`
# state. This target prunes the husks before `compose up`; the
# backend's pre-bootstrap reconciler tick (`backend/cmd/backend/main.go`)
# then cascades the orphan runtime row to `removed`, the lobby
# cancels the game, and the dev-sandbox bootstrap purges the
# cancelled tile and provisions a fresh sandbox in the same
# `make up` cycle. Healthy `running` / `restarting` containers are
# left intact so a long-lived sandbox survives normal up/down
# cycles.
# then cascades the orphan runtime row to `removed` and the lobby
# cancels the game. Healthy `running` / `restarting` containers are
# left intact so a long-lived game survives normal up/down cycles.
prune-broken-engines:
@ids=""; \
for cid in $$(docker ps -aq \
+16 -50
View File
@@ -78,49 +78,24 @@ To force the second path (no fast-bypass), edit
`make rebuild` (or simply `docker compose up -d backend` to recreate
the backend with the new env).
## Auto-provisioned dev sandbox
## No auto-provisioned game
`make up` provisions a private game called **Dev Sandbox** owned by
the dev user (default `dev@local.test`). The flow is implemented in
`backend/internal/devsandbox` and runs on every backend boot when
`BACKEND_DEV_SANDBOX_EMAIL` is non-empty in `tools/local-dev/.env`.
Bootstrap is idempotent — re-running `make up` after a `make down`
finds the existing user, dummy participants, game, and memberships
without creating duplicates. If a previous boot crashed mid-way
(game stuck in `enrollment_open` or `ready_to_start`), the next boot
resumes the lifecycle.
To log in straight into the sandbox:
`make up` brings up the stack with an empty lobby — there is no
auto-provisioned game. Sign in with email-OTP (the fixed dev code
`123456` works when `BACKEND_AUTH_DEV_FIXED_CODE` is set in
`tools/local-dev/.env`):
1. `make -C tools/local-dev up`
2. `pnpm -C ui/frontend dev` (in another terminal)
3. Open <http://localhost:5173/login>, enter `dev@local.test`, then
the dev code `123456`.
4. The lobby shows **Dev Sandbox** in *My Games*; click in.
3. Open <http://localhost:5173/login>, enter your email, then the dev
code `123456`.
To disable the bootstrap, clear `BACKEND_DEV_SANDBOX_EMAIL` in
`tools/local-dev/.env` and `docker compose up -d backend` (or
`make rebuild`). Existing users / games are not removed.
Terminal sandbox games — anything in `cancelled`, `finished`, or
`start_failed` — are deleted on every boot before find-or-create
runs. The cascade declared in `00001_init.sql` removes the
matching memberships, applications, invites, runtime records,
and player mappings in the same write, so the dev user's lobby
shows exactly one running tile at all times. Cancelling the
sandbox manually and running `docker compose restart backend`
(or `make rebuild`) yields a fresh game without leaving dead
tiles behind.
The bootstrap requires:
- `galaxy-engine:local-dev` Docker image (`make build-engine`).
- `BACKEND_DEV_SANDBOX_ENGINE_VERSION` parses as plain semver
(`MAJOR.MINOR.PATCH`); the default `0.1.0` is what the bootstrap
registers in the `engine_versions` row that points at the image.
- `BACKEND_DEV_SANDBOX_PLAYER_COUNT` ≥ 20 (the engine's minimum;
19 deterministic dummies fill the slots so the single real user
can start the game).
To exercise the map and report views without running a full game, use
the UI's DEV **synthetic report loader**: convert a legacy `.REP` with
`tools/local-dev/legacy-report/` and load the resulting JSON through the
loader (see that tool's README). To play a real game, create one in the
lobby and let the engine (`galaxy-engine:local-dev`, built by
`make build-engine`) run it.
- A frozen turn schedule (`0 0 1 1 *` — once a year) so the visible
game state stays at turn 1 until you explicitly progress it.
@@ -239,24 +214,15 @@ make status docker compose ps
this in one cycle: `prune-broken-engines` (runs as part of `up`)
removes every engine container that is not in `running` /
`restarting` state, the backend's pre-bootstrap reconciler tick
cascades the orphan runtime row to `removed`, the lobby cancels
the matching sandbox game, and the dev-sandbox bootstrap purges
the cancelled tile and provisions a fresh sandbox with a brand
new state directory. To run the cleanup by hand without restarting
the rest of the stack, `make prune-broken-engines`.
cascades the orphan runtime row to `removed`, and the lobby cancels
the matching game. To run the cleanup by hand without restarting the
rest of the stack, `make prune-broken-engines`.
The cycle relies on the backend image carrying the pre-bootstrap
reconciler tick (`backend/cmd/backend/main.go`). `make up` reuses
the cached image, so after pulling this commit the first time you
must `make rebuild` once to bake the fix in. Future `make up`
cycles will heal in one shot.
If after the heal cycle the lobby still shows only a `cancelled`
sandbox tile and no running game, the running backend image
predates the pre-bootstrap reconciler tick — the periodic ticker
cancels the orphan after bootstrap has already returned, leaving
the lobby in the half-baked state. `make rebuild` recreates the
image and then `make up` lands a fresh sandbox.
- **`make up` reports a build error mentioning `pkg/cronutil`** —
upstream module list drifted; copy any new `pkg/<name>/` line into
the local-dev `backend.Dockerfile` / `gateway.Dockerfile` to match
-4
View File
@@ -122,10 +122,6 @@ services:
BACKEND_OTEL_TRACES_EXPORTER: none
BACKEND_OTEL_METRICS_EXPORTER: none
BACKEND_AUTH_DEV_FIXED_CODE: ${BACKEND_AUTH_DEV_FIXED_CODE:-}
BACKEND_DEV_SANDBOX_EMAIL: ${BACKEND_DEV_SANDBOX_EMAIL:-}
BACKEND_DEV_SANDBOX_ENGINE_IMAGE: ${BACKEND_DEV_SANDBOX_ENGINE_IMAGE:-}
BACKEND_DEV_SANDBOX_ENGINE_VERSION: ${BACKEND_DEV_SANDBOX_ENGINE_VERSION:-}
BACKEND_DEV_SANDBOX_PLAYER_COUNT: ${BACKEND_DEV_SANDBOX_PLAYER_COUNT:-}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Per-game state directories live under the same absolute path
+4 -4
View File
@@ -85,16 +85,16 @@ report to fetch. Two alternatives were rejected:
- a brand-new `user.games.state` message — adds a full wire-flow
(fbs schema, transcoder, gateway routing, backend handler) for a
one-field response;
- hard-coding `turn=0` for all games — works for the dev sandbox
(which never advances past turn zero) but renders the initial
state for any real game past turn zero.
- hard-coding `turn=0` for all games — works for a synthetic report
loaded at turn zero but mis-renders the initial state for any real
game past turn zero.
Extending `GameSummary` reuses the existing lobby pipeline; the
backend already tracks `current_turn` in its runtime projection
(`backend/internal/server/handlers_user_lobby_helpers.go`
`gameSummaryToWire` reads it from `g.RuntimeSnapshot.CurrentTurn`).
The `current_turn` field defaults to zero on the FB side, so existing
tests and the dev sandbox flow continue to work unchanged.
tests and the synthetic-report flow continue to work unchanged.
## State binding
+4 -2
View File
@@ -1,6 +1,8 @@
// DEV-only synthetic-report loader. Backs the "Load synthetic report"
// affordance on the lobby (visible behind `import.meta.env.DEV`) and
// the in-game shell layout's bypass for the synthetic game id range.
// affordance on the lobby (visible when the build-time flag
// `VITE_GALAXY_DEV_AFFORDANCES === "true"` — the dev and dev-deploy
// bundles; stripped from prod) and the in-game shell layout's bypass
// for the synthetic game id range.
//
// The accepted JSON shape mirrors `pkg/model/report.Report` as
// emitted by `tools/local-dev/legacy-report/cmd/legacy-report-to-json`.