Server-rendered admin console in the backend at /_gm (internal/adminconsole), fronted on the gateway's public listener by Basic-Auth + a verbatim reverse proxy (mounted on the edge mux below the h2c wrap). A same-origin check guards its POSTs; no operator identity is tracked. This supersedes the Stage 6 gateway-fronts- /api/v1/admin model: GATEWAY_ADMIN_ADDR and the backend /api/v1/admin ping are dropped and gateway/internal/admin is repurposed to the verbatim proxy. - Complaints: migration 00008 (+ jetgen) adds disposition/resolution_note/ resolved_at/applied_in_version + the deferred status CHECK; resolution feeds a query-derived pending dictionary-change pipeline (marked applied after a reload). - Dictionary hot-reload: per-version subdir BACKEND_DICT_DIR/<version>/ via the new Registry.LoadAvailable; engine.OpenWithVersions restores resident versions on restart. Partially addresses TODO-2. - Broadcasts: a backend Telegram-connector client (internal/connector, BACKEND_CONNECTOR_ADDR) for SendToUser / SendToGameChannel (discharges the Stage 9 forward-note). - Admin reads: account.ListAccounts/CountAccounts/Identities and game.ListGames/CountGames/GameByID/ListComplaints/GetComplaint/CountComplaints/ ResolveComplaint/DictionaryChanges/MarkChangesApplied. - Tests: adminconsole render, engine reload, same-origin guard, gateway verbatim proxy + h2c console mount, inttest complaint pipeline + list/count + /_gm console. - Docs: PLAN (Stage 10 done + refinements + TODO-2), ARCHITECTURE §1/§5/§6/§12/§13, FUNCTIONAL (+_ru), TESTING, backend/gateway READMEs.
backend
Internal-only domain service for the Scrabble platform (module scrabble/backend).
It owns identity/sessions, accounts, and — in later stages — the lobby, game
runtime, robot, chat, history and administration. Its only network consumers are
the gateway and the platform side-services; it is never exposed publicly.
As of Stage 1 the backend provides the foundation: configuration, the HTTP
listener with the /api/v1 route-group skeleton and probes, the Postgres pool
with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache,
and the durable accounts / identities / sessions data model. The session and
account REST endpoints are added with the gateway (Stage 6); Stage 1 ships the
store/service layer they will call.
Stage 2 adds internal/engine, the in-process bridge to the scrabble-solver
library: a versioned dictionary registry, a deterministic tile bag, and a pure
rules Game (legal plays, passes, exchanges, resignations and end-condition
detection) that emits dictionary-independent move records. It is a library only;
the game domain wires it into the process in Stage 3.
Stage 3 adds internal/game, the game domain over the engine. Active games are
event-sourced: a games row plus an append-only decoded move journal, with the
live engine.Game kept warm in a cache and rebuilt by replay on a miss. It
provides create, the play/pass/exchange/resign transitions, an unlimited
score/legality preview, the hint (per-game allowance plus a profile wallet), the
word-check tool with complaint capture, per-player game state, history and GCG
export, per-account statistics on finish, and a background turn-timeout sweeper
that auto-resigns overdue turns (honouring each player's daily away window). Like
Stages 1–2 it is a service/store layer; the HTTP surface lands with the
gateway (Stage 6).
Stage 4 adds the lobby and social fabric. internal/lobby holds an in-memory
matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and
friend-game invitations (invite → accept, starting a 2–4 player game once every
invitee accepts). internal/social owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. internal/account
gains profile editing and the email confirm-code flow (a Mailer seam: SMTP or a
development log mailer). The engine now also handles multi-player drop-out: in
a 3–4 player game a resignation or timeout drops that seat and the rest play on
(the tile disposition is a per-game setting), the game ending when one active seat
remains. As before this is a service/store layer — chat and nudges are persisted
but their live delivery, and all REST endpoints, arrive with the gateway
(Stage 6); the services are exposed via Server accessors for those handlers.
Stage 5 adds the robot opponent (internal/robot). A pool of durable accounts —
each a kind='robot' identity, provisioned at startup with chat and friend
requests blocked — backs a human-like name pool. A background driver plays the
robot's moves through the public game API as an ordinary seated player (so only
internal/engine imports the solver): it decides once per game whether to play to
win (≈ 40%), targets a small score margin, and times its moves with a right-skewed
delay, a night-sleep window anchored to the opponent's timezone, and nudge
behaviour — all derived deterministically from the game seed, so it keeps no extra
state. The matchmaker now substitutes a pooled robot after a 10-second wait and
exposes Poll so a waiting player can collect the started game (the live
match-found notification arrives with the gateway).
Stage 6 opens the backend to the edge. The route groups gain their first
handlers (internal/server/handlers_*.go): gateway-only session endpoints under
/api/v1/internal (Telegram/guest/email login → mint, resolve, revoke) and a
slice of authenticated /api/v1/user operations (profile, submit play, game
state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history
operations under /api/v1/user: friends/* (request/respond/cancel/unfriend,
list/incoming, the one-time code issue/redeem), blocks/*, invitations/*
(create/accept/decline/cancel/list), PUT profile, email/{request,confirm},
stats, and games/:id/gcg (finished-only). A new internal/notify hub feeds a
second listener — internal/pushgrpc, a gRPC server (BACKEND_GRPC_ADDR) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. Stage 9 adds the gateway-only POST /api/v1/internal/push-target (a user's
Telegram external_id, language and notifications_in_app_only flag) that the gateway
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
seed a new account's language and display name from the launch fields, and adds
migration 00007 (accounts.notifications_in_app_only, default true).
Migration 00005 adds accounts.is_guest: an ephemeral guest is a durable row
with no identity, excluded from statistics. Stage 10 adds the server-rendered
admin console at /_gm (internal/adminconsole + internal/server/handlers_admin_console.go;
the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs), the
complaint resolution lifecycle (migration 00008 adds disposition/resolution_note/
resolved_at/applied_in_version + the status CHECK) feeding a dictionary-change
pipeline, dictionary hot-reload from BACKEND_DICT_DIR/<version>/
(engine.OpenWithVersions / Registry.LoadAvailable), and operator broadcasts via a
backend Telegram-connector client (internal/connector, BACKEND_CONNECTOR_ADDR). The
shared wire contracts live in the sibling ../pkg module.
Package layout
cmd/backend/ # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> robot pool+driver -> lobby+social -> server
cmd/jetgen/ # dev tool: regenerate go-jet code from a throwaway container
internal/config/ # env configuration (composes postgres + telemetry + game config)
internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
migrations/ # embedded *.sql (goose), schema `backend`
jet/ # generated go-jet models + table builders (committed)
internal/account/ # durable accounts + platform/email identities (store)
internal/session/ # opaque tokens, sessions store, write-through cache, service
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts)
Configuration (environment)
| Variable | Default | Notes |
|---|---|---|
BACKEND_HTTP_ADDR |
:8080 |
HTTP (REST) listen address. |
BACKEND_GRPC_ADDR |
:9090 |
gRPC listen address for the live-event push stream to the gateway. |
BACKEND_LOG_LEVEL |
info |
debug / info / warn / error. |
BACKEND_POSTGRES_DSN |
— | Required. pgx/libpq URL; must pin search_path=backend. |
BACKEND_POSTGRES_MAX_OPEN_CONNS |
25 |
Pool max open connections. |
BACKEND_POSTGRES_MAX_IDLE_CONNS |
5 |
Pool max idle connections. |
BACKEND_POSTGRES_CONN_MAX_LIFETIME |
30m |
Max connection lifetime. |
BACKEND_POSTGRES_OPERATION_TIMEOUT |
5s |
Connect attempt + /readyz ping bound. |
BACKEND_SERVICE_NAME |
scrabble-backend |
OpenTelemetry service.name. |
BACKEND_OTEL_TRACES_EXPORTER |
none |
none or stdout (OTLP arrives later). |
BACKEND_OTEL_METRICS_EXPORTER |
none |
none or stdout. |
BACKEND_DICT_DIR |
— | Required. Directory of committed .dawg dictionaries. |
BACKEND_DICT_VERSION |
v1 |
Dictionary version new games pin. |
BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL |
1m |
How often the turn-timeout sweeper runs. |
BACKEND_GAME_CACHE_TTL |
24h |
Idle window before a live game is evicted from cache. |
BACKEND_LOBBY_ROBOT_WAIT |
10s |
Auto-match wait before a robot is substituted for a missing human. |
BACKEND_LOBBY_REAPER_INTERVAL |
1s |
How often the substitution reaper scans for over-waited players. |
BACKEND_ROBOT_DRIVE_INTERVAL |
30s |
How often the robot driver scans for due robot turns. |
BACKEND_SMTP_HOST |
— | Email relay host. Empty selects the development log mailer (the confirm-code is logged, not sent). |
BACKEND_SMTP_PORT |
587 |
Email relay port. |
BACKEND_SMTP_USERNAME |
— | SMTP user; empty relays without authentication. |
BACKEND_SMTP_PASSWORD |
— | SMTP password. |
BACKEND_SMTP_FROM |
no-reply@localhost |
Envelope/From address for confirm-codes. |
BACKEND_CONNECTOR_ADDR |
— | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
Run
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=../../scrabble-solver/dawg \
go run ./cmd/backend
On boot the backend opens the pool, creates the backend schema if needed,
applies the embedded migrations, loads the dictionaries into the engine registry
(a hard dependency — a missing dictionary aborts the boot), warms the session
cache and starts the game turn-timeout sweeper. GET /healthz reports liveness;
GET /readyz reports 200 only when the database answers and the session cache is
warmed.
Migrations & generated code
Migrations are plain goose SQL under internal/postgres/migrations (sequential
NNNNN_name.sql), embedded and applied at startup. After changing the schema,
regenerate the committed go-jet code (needs Docker):
go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp container
Engine & dictionaries
internal/engine consumes the sibling scrabble-solver module in-process. Its
bare module path (scrabble-solver, not a URL) cannot be fetched via VCS, so the
workspace go.work carries replace scrabble-solver => ../scrabble-solver and
the build must run from the repository root (the workspace), not from this module
in isolation. github.com/iliadenisov/dafsa (the DAWG loader) is a direct
dependency. CI clones the public solver repository into ../scrabble-solver
before building (see .gitea/workflows/); locally, check it out next to this
repository. Committed dictionaries (en_sowpods.dawg, ru_scrabble.dawg,
ru_erudit.dawg) live in the solver's dawg/ directory; the engine loads them
by (variant, dict_version) from a directory path. Since Stage 3 the backend
loads them at startup from the required BACKEND_DICT_DIR (a missing
dictionary aborts the boot); the future versioned-artifact direction is recorded
in ../PLAN.md TODO-2.
Tests
go test -count=1 ./... # unit tests (no Docker)
go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker)
Integration tests are guarded by the integration build tag and run against a
throwaway postgres:17-alpine container; they fail loudly when Docker is absent
rather than skipping. The internal/engine tests load the committed DAWGs from
BACKEND_DICT_DIR (defaulting to the sibling ../scrabble-solver/dawg) and fail
loudly when that directory is absent.