Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts) (#11)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s

This commit was merged in pull request #11.
This commit is contained in:
2026-06-04 07:27:49 +00:00
parent 4c4beace85
commit 3a640a17a4
49 changed files with 2548 additions and 200 deletions
+23 -14
View File
@@ -14,8 +14,9 @@ Three executables plus per-platform side-services:
- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
anti-abuse (rate limiting), authenticates the player against the originating
platform (or an email/guest session), resolves the internal `user_id`, and
forwards authenticated traffic to `backend` with an `X-User-ID` header. Hosts an
admin surface behind HTTP Basic Auth. Bridges live events from `backend` to the
forwards authenticated traffic to `backend` with an `X-User-ID` header. Serves the
backend's admin console at `/_gm` on its public listener behind HTTP Basic Auth.
Bridges live events from `backend` to the
client. The shared wire contracts (the push proto and the FlatBuffers edge
payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`.
- **`backend`** — internal-only service that owns every domain concern:
@@ -47,7 +48,7 @@ Three executables plus per-platform side-services:
`scrabble/platform/telegram`). It is the only component holding the bot token: it
runs the Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves
a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation
and out-of-app push) and `backend` (admin messaging — Stage 10) call over the
and out-of-app push) and `backend` (operator broadcasts) call over the
trusted internal network. Its generic delivery methods are **platform-agnostic**
(keyed by the identity `external_id`), so a future VK/MAX connector reuses them; only
initData validation is Telegram-specific. It runs in its own container, egressing to
@@ -62,7 +63,7 @@ flowchart LR
Backend -- pgx --> Postgres[(Postgres)]
Backend -. embeds .- Solver[[scrabble-solver library]]
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector]
Backend -. admin gRPC, Stage 10 .-> Telegram
Backend -. operator broadcasts (gRPC) .-> Telegram
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram))
```
@@ -166,11 +167,15 @@ Key points:
word-check tool through `Registry.Lookup`.
- **Dictionary versioning — pin per game.** A game records the `dict_version` it
started on and finishes on that version; new games use the latest. Multiple
versions may be resident at once. An admin reload *(planned, Stage 10)*
registers a new version through `Registry.Load`; delivery is the DAWG file in
the image / a volume mounted at the dictionary directory. (A future split of
the solver into engine + dictionary generator with versioned artifacts is
recorded in [`../PLAN.md`](../PLAN.md) TODO-2.)
versions may be resident at once. The boot version loads from the flat
`BACKEND_DICT_DIR`; the admin console **hot-reloads** a new version from a
per-version subdirectory `BACKEND_DICT_DIR/<version>/` through
`Registry.LoadAvailable` (only the variants whose DAWG is present there), and a
restart re-loads every resident version via `engine.OpenWithVersions` (the flat
boot version plus each subdirectory). In-flight games keep their pinned version;
new games use the latest. (A future split of the solver into engine + dictionary
generator with versioned artifacts is recorded in [`../PLAN.md`](../PLAN.md)
TODO-2.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
@@ -231,8 +236,11 @@ Key points:
"no options" rather than "no hints left".
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
dictionary; each result offers a **complaint** (complainant, game, variant,
dict_version, word, the disputed result, an optional note) that lands in an
admin review queue *(admin side planned, Stage 10)*.
dict_version, word, the disputed result, an optional note) that lands in the admin
review queue. An operator resolves it (`open → resolved`) with a **disposition**
reject, accept-add or accept-remove; the accepted ones form a derived
**pending-changes** list that feeds the offline dictionary rebuild and is marked
applied once the rebuilt version is hot-reloaded (§5, §12).
## 7. Robot opponent
@@ -439,7 +447,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
| Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints |
| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`) on the public `/_gm/*` path and reverse-proxies it **verbatim** to the backend's server-rendered admin console; the backend trusts the gateway (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
@@ -459,8 +467,9 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini
App under `/telegram/`** (the gateway serves the static UI build; outside Telegram
that path redirects to the root), the gateway public surface and the admin surface
share one host that terminates TLS. The **Telegram connector** runs as a separate
that path redirects to the root), the gateway public surface and the **admin console
at `/_gm`** (backend-rendered, Basic-Auth at the gateway) share one host that
terminates TLS. The **Telegram connector** runs as a separate
container with **no public ingress** — it long-polls Telegram and egresses through a
VPN sidecar, answering only internal gRPC. MVP runs one `gateway`, one `backend`, one
Postgres, plus the connector. The connector's Docker/compose ships now
+12 -2
View File
@@ -110,5 +110,15 @@ wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus).
### Administration *(Stage 10)*
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
versions, and inspects users/games.
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
renders it; the gateway gates it with HTTP Basic Auth on its public listener and
proxies it verbatim. The console lists and inspects **users** (profile, statistics,
identities, their games) and **games** (summary + seats), works the **word-complaint
review queue** — resolving each as reject / accept-add / accept-remove — and exposes
the **dictionary**: the resident versions per variant, a **hot-reload** of a new
version from `BACKEND_DICT_DIR/<version>/`, and the **pending wordlist changes**
derived from accepted complaints (which feed the offline rebuild and are marked
applied after a reload). When a Telegram connector is configured an operator can also
**message a user** (by their Telegram identity) or **post to the game channel**.
State-changing actions are protected by a same-origin check; the console tracks no
operator identity.
+11 -2
View File
@@ -113,5 +113,14 @@ push доставляется через платформу.
ход, уже включающий все образованные им слова и бонус за все фишки).
### Администрирование *(Stage 10)*
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
словаря, смотрит пользователей/игры.
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова**
закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**:
резидентные версии по вариантам, **горячая перезагрузка** новой версии из
`BACKEND_DICT_DIR/<version>/` и **список ожидающих правок**, выведенный из принятых
жалоб (он питает офлайн-пересборку и отмечается применённым после перезагрузки). Если
подключён Telegram-коннектор, оператор также может **написать пользователю** (по его
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
защищены проверкой same-origin; личность оператора не отслеживается.
+11
View File
@@ -82,6 +82,17 @@ tests or touching CI.
in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account
operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
the profile-update away round-trip) and a `notify`-event constructor round-trip.
- **Admin & dictionary ops** *(Stage 10)*`backend/internal/adminconsole` unit-tests
the template renderer over every page plus the embedded asset; `backend/internal/engine`
adds the **dictionary hot-reload** cases (`LoadAvailable` loads only the present
variants, `OpenWithVersions` scans version subdirectories, a reload registers a new
version and moves "latest"); `backend/internal/server` unit-tests the console's
**same-origin** CSRF guard; the gateway adds the **verbatim `/_gm` Basic-Auth proxy**
(401 / forward, path preserved) and the h2c **console mount** (routed when configured,
404 when not). Postgres-backed `inttest` drives the **complaint resolution →
dictionary-change pipeline** (file → resolve with a disposition → pending change → mark
applied), the admin **list/count** read queries, and the **/_gm console over HTTP**
(pages render; a resolve POST needs a same-origin header).
## Principles