R6(a): de-stage code, docs, READMEs; split stage6_test

Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

- Rename the only stage-named identifiers: registerStage8 -> registerSocialOps,
  registerStage11 -> registerLinkOps (gateway transcode).
- Split stage6_test.go: TestEmailLoginFlow -> email_test.go,
  TestGuestAutoMatchLeavesNoStats (+ provisionGuest) -> account_test.go.
- Regenerated proto bindings (push.pb.go, telegram_grpc.pb.go) from the de-staged
  .proto comments; FB Go/TS bindings unchanged (flatc strips schema comments).

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
This commit is contained in:
Ilia Denisov
2026-06-10 16:56:03 +02:00
parent a372343797
commit 8881214213
156 changed files with 749 additions and 778 deletions
+39 -41
View File
@@ -1,24 +1,24 @@
# 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.
It owns identity/sessions, accounts, 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.
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 live in the `gateway`; the backend ships the store/service layer they
call.
Stage 2 adds `internal/engine`, the in-process bridge to the `scrabble-solver`
`internal/engine` is 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.
the game domain wires it into the process.
Stage 3 adds `internal/game`, the game domain over the engine. Active games are
`internal/game` is 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
@@ -26,10 +26,9 @@ 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 12 it is a service/store layer; the HTTP surface lands with the
`gateway` (Stage 6).
the engine it is a service/store layer; the HTTP surface lives in the `gateway`.
Stage 4 adds the lobby and social fabric. `internal/lobby` holds an in-memory
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 24 player game once every
invitee accepts). `internal/social` owns the friend graph (request/accept),
@@ -41,10 +40,10 @@ development log mailer). The engine now also handles **multi-player drop-out**:
a 34 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.
but their live delivery, and all REST endpoints, live in the `gateway`; the
services are exposed via `Server` accessors for those handlers.
Stage 5 adds the robot opponent (`internal/robot`). A pool of durable accounts —
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 human-like, per-language composed names. A background driver plays the
robot's moves through the public game API as an ordinary seated player (so only
@@ -53,28 +52,28 @@ win (≈ 40%), targets a small score margin, and times its moves with a move-num
right-skewed delay (quick openings, long endgames), 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 (matching the game's language) after a 10-second wait and
exposes `Poll` so a waiting player can collect the started game — R4 made it the **stream-down
exposes `Poll` so a waiting player can collect the started game — it is the **stream-down
fallback**: while the client is streaming, the live match-found push (enriched with the recipient's
initial game state) drives it instead.
Stage 6 opens the backend to the edge. The route groups gain their first
The backend opens 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,
state, lobby enqueue/poll, chat). 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
`stats`, and `games/:id/gcg` (finished-only). The `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
the `accounts.notifications_in_app_only` flag (default true).
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
route out-of-app push to the Telegram connector; the Telegram login
seeds a new account's language and display name from the launch fields, and the
`accounts.notifications_in_app_only` flag (default true).
`accounts.is_guest` marks an ephemeral guest — a durable row
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
with no identity, excluded from statistics. 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 (the `complaints` `disposition`/`resolution_note`/
@@ -82,13 +81,13 @@ the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs
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`) — each
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
`accounts.service_language`: the language tag of the bot a Telegram
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
holds the language tag of the bot a Telegram
user last signed in through, written on every login and returned by
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link`
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
@@ -98,9 +97,9 @@ shared finished game's foreign keys hold); a shared **active** game blocks the m
The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
**R3** adds rate-limit observability: the gateway posts its periodic rejection
Rate-limit observability: the gateway posts its periodic rejection
summaries to `POST /api/v1/internal/ratelimit/report`; `internal/ratewatch` keeps a
bounded in-memory episode window for the console's **Throttled** page and applies the
conservative auto-flag — an account sustaining `BACKEND_HIGHRATE_FLAG_THRESHOLD`
@@ -119,8 +118,8 @@ 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) + email/identity link primitives
internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11)
internal/link/ # link/merge orchestrator over account + accountmerge + session (Stage 11)
internal/accountmerge/ # single-transaction merge of a secondary account into a primary
internal/link/ # link/merge orchestrator over account + accountmerge + session
internal/session/ # opaque tokens, sessions store, write-through cache, service (incl. RevokeAllForAccount)
internal/server/ # gin engine, route groups, X-User-ID middleware, probes
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
@@ -130,7 +129,7 @@ internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + frien
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)
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag (R3)
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
```
## Configuration (environment)
@@ -163,7 +162,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged (R3). |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
| `BACKEND_HIGHRATE_FLAG_WINDOW` | `10m` | The rolling window those rejections accumulate over. |
## Run
@@ -209,9 +208,8 @@ local solver co-development you may add a temporary replace — see `go.work`).
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14
(TODO-1/TODO-2).
`BACKEND_DICT_DIR`. The backend loads them at startup as a hard dependency
(a missing dictionary aborts the boot).
## Tests