14 Commits

Author SHA1 Message Date
developer 8c8f8c4d42 Merge pull request 'Stage 15: dual Telegram bots & language-gated variants' (#16) from feature/stage-15-language-service-split into master
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 19s
2026-06-05 07:40:53 +00:00
Ilia Denisov e9f836db87 Stage 15: dual Telegram bots & language-gated variants
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Service-agnostic refinement of the owner's idea: the sign-in service returns a
set of supported game languages with the user identity, and the lobby gates the
New Game variant choice by it (en -> English; ru -> Russian + Эрудит).

- Connector hosts two bots in one container (one per service language, each its
  own token + game channel; the same telegram_id spans both). ValidateInitData
  tries each token and returns the validating bot's service_language +
  supported_languages. Per-language config (TELEGRAM_BOT_TOKEN_EN/_RU, channels).
- supported_languages rides the Session (fbs, session-scoped, not persisted); the
  UI offers only the matching variants on New Game — gating only the START of a
  new game (auto-match + friend invite), not accept/open/play; backend does not
  enforce.
- service_language persisted (accounts.service_language, migration 00010, written
  every login, last-login-wins) and routes the user-facing Notify push back
  through the right bot (push-target coalesces with preferred_language).
- Admin SendToUser/SendToGameChannel gain an operator-chosen language selector in
  the console (unrelated to ValidateInitData).
- Non-Telegram logins carry the gateway default set
  (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants).

Wire (committed regen): ValidateInitDataResponse +service_language
+supported_languages; Session +supported_languages; SendToUser/SendToGameChannel
+language. Docs (ARCHITECTURE/FUNCTIONAL/_ru/READMEs) + PLAN updated; stage marked done.
2026-06-05 09:35:53 +02:00
Ilia Denisov 23b5c3b5cc refine plan stage order 2026-06-05 08:17:00 +02:00
developer e7c9d301ba Merge pull request 'Stage 14: solver & dictionary split (publish solver + scrabble-dictionary artifact)' (#15) from feature/stage-14-solver-dictionary-split into master
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
2026-06-05 06:03:36 +00:00
Ilia Denisov ec435c0e7f Stage 14: solver & dictionary split — consume published module + DAWG artifact (TODO-1/TODO-2)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
- backend/go.mod pins gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0; the engine's
  imports use the published module path; go.work drops the solver replace (GOPRIVATE fetches
  it directly from Gitea). The solver's wordlist/dictdawg are now public packages.
- CI (go-unit, integration): drop the solver sibling-clone, set GOPRIVATE, and download the
  dictionary DAWG release artifact (scrabble-dawg-<DICT_VERSION>.tar.gz from the new
  scrabble-dictionary repo) for BACKEND_DICT_DIR.
- Docs: ARCHITECTURE §5/§11/§13/§14 + backend/README updated to the published-module +
  release-artifact model. PLAN.md re-scoped Stage 14 to the split and added Stages 15 (deploy
  infra & test contour), 16 (prod contour), 17 (dual Telegram bots); TODO-1/TODO-2 marked done.
2026-06-04 20:00:36 +02:00
developer da6665b967 Merge pull request 'Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)' (#14) from feature/stage-13-alphabet-on-the-wire into master
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
2026-06-04 15:00:57 +00:00
Ilia Denisov 90eaf4964b Stage 13: alphabet on the wire (UI alphabet-agnostic, TODO-4)
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Successful in 19s
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 19s
Live play now exchanges per-variant alphabet indices instead of concrete
letters (rack out; submit-play, evaluate, exchange, word-check in). The client
caches each variant's (index, letter, value) table behind
StateRequest.include_alphabet and renders the rack and blank chooser from it,
dropping the hardcoded value/alphabet tables. History, the durable journal and
GCG stay decoded concrete characters (ARCHITECTURE §9.1, unchanged).

- pkg/fbs: new AlphabetEntry + PlayTile; StateView.rack -> [ubyte] + alphabet;
  StateRequest.include_alphabet; SubmitPlay/Eval tiles -> [PlayTile];
  Exchange tiles + CheckWord word -> [ubyte] (committed Go + TS regenerated).
- engine: AlphabetTable + a cached per-variant codec (LetterForIndex/EncodeRack/
  DecodeTiles/DecodeWord) + BlankIndex sentinel; Go parity test.
- backend server edge maps index<->letter (new thin game.Service.GameVariant);
  game.Service domain methods, engine.Game and the robot keep one letter-based
  play path. The gateway forwards indices verbatim (no alphabet table).
- ui: lib/alphabet.ts in-memory cache; codec encodes/decodes indices; premiums.ts
  is geometry-only; the mock seeds a fixture table; the UI normalises display to
  upper case (codec + cache), leaving placement/board/checkword unchanged.

Parity moved to the Go engine.AlphabetTable test; premiums.ts loses its value
tables. Discharges TODO-4.
2026-06-04 16:26:43 +02:00
developer 6537082397 Merge pull request 'Stage 12: observability & performance (OTel/OTLP, metrics, guest GC)' (#13) from feature/stage-12-observability into master
Tests · Go / test (push) Successful in 10s
Tests · Integration / integration (push) Successful in 13s
2026-06-04 12:57:53 +00:00
Ilia Denisov d99705645f Stage 12: mark done in the stage tracker (CI green)
Tests · Go / test (pull_request) Successful in 9s
Tests · Integration / integration (pull_request) Successful in 12s
2026-06-04 14:24:42 +02:00
Ilia Denisov dcd8de8b00 Stage 12: observability & performance (OTel/OTLP, domain metrics, guest GC)
Tests · Go / test (push) Successful in 11s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 10s
Tests · Integration / integration (pull_request) Successful in 11s
- pkg/telemetry: shared OTel provider bootstrap (none/stdout/otlp + W3C
  propagators + Go runtime metrics); backend/internal/telemetry becomes a thin
  facade keeping its gin middleware.
- Telemetry parity: gateway and the Telegram connector gain telemetry runtimes
  and config (GATEWAY_/TELEGRAM_ SERVICE_NAME + OTEL_*); otelgrpc instruments the
  backend push server, the gateway's backend+connector clients and the connector
  server. Default exporter stays none (collector/dashboards are Stage 14).
- Operational metrics (variant attribute on game-scoped ones): game_replay_duration,
  game_move_validate_duration, games_started_total, games_abandoned_total,
  game_cache_active, chat_messages_total{kind}, gateway edge_request_duration.
  Wired via the SetMetrics setter pattern (default no-op meter).
- TODO-3: account.GuestReaper deletes guests with no game seat past
  BACKEND_GUEST_RETENTION (default 30d, swept every BACKEND_GUEST_REAP_INTERVAL).
- Tests: pkg/telemetry exporter selection; game/social/edge metric recording via
  a manual reader; config (otlp accepted, guest knobs); inttest guest reaper.
- Docs: PLAN.md re-scopes Stage 12 and adds Stage 13 (alphabet-on-wire) + Stage 14
  (CI/deploy) with the agreed dictionary-versioning resolution; ARCHITECTURE 11/13,
  TESTING, the three READMEs and FUNCTIONAL(+ru) updated.
2026-06-04 14:22:15 +02:00
developer 01485d8fc6 Stage 11: account linking & merge (email + Telegram Login Widget) (#12)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 18s
2026-06-04 09:18:17 +00:00
developer 3a640a17a4 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
2026-06-04 07:27:49 +00:00
developer 4c4beace85 Stage 9: Telegram integration (connector, Mini App, out-of-app push) (#10)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 19s
2026-06-04 05:12:54 +00:00
Ilia Denisov cf66ed7e26 Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
  (renders a localized message + deep-link button), SendToUser/SendToGameChannel
  (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
  (VPN sidecar, no public ingress); README.

Gateway:
- initData validation relocated from the gateway into the connector; the gateway
  calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
  and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
  stream to connector.Notify, gated by /internal/push-target + the in-app-only
  flag (race-free de-dup); HasSubscribers added to the push hub.

Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
  fields; IdentityExternalID reverse lookup; /internal/push-target handler.

UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
  route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
  Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
  share-to-Telegram link for a friend code. Vitest + Playwright coverage.

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 07:10:21 +02:00
223 changed files with 13580 additions and 1438 deletions
+6
View File
@@ -0,0 +1,6 @@
# Keep Docker build contexts small (the connector builds from the repo root).
.git
**/node_modules
ui/dist
ui/test-results
ui/playwright-report
+22 -11
View File
@@ -11,6 +11,7 @@ on:
- 'backend/**'
- 'gateway/**'
- 'pkg/**'
- 'platform/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/go-unit.yaml'
@@ -20,6 +21,7 @@ on:
- 'backend/**'
- 'gateway/**'
- 'pkg/**'
- 'platform/**'
- 'go.work'
- 'go.work.sum'
- '.gitea/workflows/go-unit.yaml'
@@ -31,16 +33,25 @@ jobs:
defaults:
run:
shell: bash
env:
# The engine consumes the published scrabble-solver module from this Gitea;
# GOPRIVATE makes go fetch it directly (skipping the public proxy/checksum DB).
# DICT_VERSION selects the dictionary DAWG release the engine tests load.
GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch scrabble-solver (sibling)
# The engine package consumes scrabble-solver in-process; go.work points
# its bare module path at this sibling checkout. The repository is public,
# so the clone needs no credentials. It tracks master HEAD (see PLAN.md
# TODO-1 for the move to a published, versioned module).
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
- name: Fetch dictionary DAWGs
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
# sibling clone). They ship as a release artifact, one semver per set.
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
@@ -56,15 +67,15 @@ jobs:
fi
- name: vet
run: go vet ./backend/... ./pkg/... ./gateway/...
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
- name: build
run: go build ./backend/... ./pkg/... ./gateway/...
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
- name: test
# -count=1 disables the test cache so a green run never depends on a
# previous runner's cached state. BACKEND_DICT_DIR points the engine
# tests at the committed DAWGs in the sibling checkout.
# tests at the DAWGs fetched from the dictionary release.
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
run: go test -count=1 ./backend/... ./pkg/... ./gateway/...
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
+17 -8
View File
@@ -35,16 +35,25 @@ jobs:
# Ryuk (testcontainers' reaper) does not start cleanly on every runner;
# the suite's TestMain terminates its own container, so disable it.
TESTCONTAINERS_RYUK_DISABLED: "true"
# The engine consumes the published scrabble-solver module from this Gitea
# (GOPRIVATE -> direct fetch, skipping the public proxy/checksum DB);
# DICT_VERSION selects the dictionary DAWG release the engine tests load.
GOPRIVATE: gitea.iliadenisov.ru/*
DICT_VERSION: v1.0.0
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch scrabble-solver (sibling)
# The backend now imports the engine package, which consumes
# scrabble-solver in-process; go.work points its bare module path at this
# sibling checkout. The repository is public, so the clone needs no
# credentials. It tracks master HEAD (see PLAN.md TODO-1).
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
- name: Fetch dictionary DAWGs
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
# sibling clone). They ship as a release artifact; the engine's untagged
# tests (compiled here too) load them.
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
@@ -56,7 +65,7 @@ jobs:
# -count=1 disables the test cache; -p=1 -parallel=1 keeps the
# container-backed tests serial; the 15-minute timeout bounds a stuck
# container pull. The engine package's (untagged) tests also compile and
# run here, so BACKEND_DICT_DIR points them at the committed DAWGs.
# run here, so BACKEND_DICT_DIR points them at the DAWGs from the release.
env:
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
+3 -1
View File
@@ -111,7 +111,8 @@ backend/ # module scrabble/backend
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
internal/inttest/ # //go:build integration Postgres-backed tests
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ platform/ # added by their stages
gateway/ ui/ pkg/ # added by their stages
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
```
## Build & test
@@ -121,6 +122,7 @@ go build ./backend/... # per module ('./...' from the root won't span t
go vet ./backend/...
gofmt -l . # must print nothing
go test -count=1 ./backend/...
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9)
go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
+482 -43
View File
@@ -42,10 +42,15 @@ independent (see ARCHITECTURE §9.1).
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
| 9 | Telegram integration (bot side-service, deep-link, push) | todo |
| 10 | Admin & dictionary ops (complaint review, version reload) | todo |
| 11 | Account linking & merge | todo |
| 12 | Polish (observability, perf with evidence, deploy) | todo |
| 9 | Telegram integration (bot side-service, deep-link, push) | **done** |
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
| 11 | Account linking & merge | **done** |
| 12 | Observability & performance (telemetry, metrics, guest GC) | **done** |
| 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** |
| 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** |
| 15 | Dual Telegram bots & language-gated variants | **done** |
| 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | todo |
| 17 | Prod contour deploy (SSH export/import, manual after merge) | todo |
Scaffolding is incremental: `go.work` lists only existing modules; each stage
adds the modules it needs.
@@ -204,10 +209,103 @@ dedupe). High blast-radius — focused regression tests.
Open details: conflict resolution (active games on both, duplicate friends,
display-name collisions); irreversibility/audit; confirm-flow per platform.
### Stage 12 — Polish
Scope: observability dashboards, evidence-based performance work, prod
build/deploy.
Open details: deployment target/host; dashboards; load expectations.
### Stage 12 — Observability & performance
Scope: wire a configurable **OTLP** exporter (alongside `none`/`stdout`), shared in a
new `pkg/telemetry`; add telemetry to the **gateway** and the **Telegram connector**
(providers + `otelgrpc` on the gRPC hops) for parity with the backend; add
domain/operational **metrics** close to the business (game replay/validate timings,
started/abandoned games, live-cache size, chat/nudge counts, the edge roundtrip, Go
runtime metrics); discharge **TODO-3** (abandoned-guest GC). The OTLP collector and
dashboards are stood up with the deploy (Stage 15); the default exporter stays `none`,
so CI needs no collector. Performance is operational-metric instrumentation, not
speculative optimisation (the standing "evidence first" rule — no measured hotspot yet).
Open details: exporter default and whether a collector is stood up now; the metric set
and its attributes; the guest-reaper trigger given revoke-only sessions.
### Stage 13 — Alphabet on the wire (TODO-4)
Scope: make the UI **alphabet-agnostic**. On game-screen load the client receives the
variant's alphabet table `(letter, index, value)` for **display only**, caches it in
memory by variant (a request flag gates whether the table is included, so it is not
resent on every state poll); live play then exchanges **letter indices** both ways, and
**word-check** sends indices, constraining input to the variant's alphabet. The engine
already works in alphabet-index bytes, so the wire does *less* decoding in live play; the
durable journal / history / GCG stay decoded concrete characters (the §9.1
dictionary-independent invariant is untouched). The alphabet comes from the **solver's
rules** (not the DAWG), so the wire table is pinned by the solver version. **Index-drift
caveat:** the running solver, the DAWGs (built against it — Stage 14 / TODO-2) and the
wire table must agree, or letter indexing silently corrupts. Blast radius: `pkg/fbs`
(a new Alphabet table; index fields in `StateView`/rack and in
`SubmitPlay`/`Exchange`/`check_word`) → backend DTO encode/decode → UI
`codec.ts`/`premiums.ts` → board/rack render, the move/exchange/word-check senders, the
mock transport and the Vitest tests.
Open details: the fbs shape and `include_alphabet` flag placement; whether to keep
concrete-letter fields during the transition; whether tile exchange moves fully to
indices; the premiums.ts parity-test rework.
### Stage 14 — Solver & dictionary split (TODO-1 + TODO-2)
Re-scoped from the original "CI & deploy": that was several sessions of work, so the
deploy + observability + the two-bots idea were split into **Stages 1517** below and this
stage took only the dependency/artifact split that everything else builds on. Scope: publish
`scrabble-solver` as a versioned Gitea module and split the dictionary build into a new
`scrabble-dictionary` repo delivering a **release artifact**, then make `scrabble-game` consume
both — discharging **TODO-1** and **TODO-2**.
- **TODO-1 — solver published.** `scrabble-solver` renamed to module
`gitea.iliadenisov.ru/developer/scrabble-solver`, tagged **v1.0.0**; `wordlist`/`dictdawg`
de-internalised to public packages (the dict repo imports them); `cmd/builddict`/`dictprep`/the
`dictionaries` submodule moved out; `internal/dict` repointed at the committed `dawg/*.dawg`
fixtures. `backend/go.mod` pins `v1.0.0`; the `go.work` replace and the CI sibling-clone are
gone; `GOPRIVATE=gitea.iliadenisov.ru/*` makes go fetch it directly (no public proxy/checksum DB).
- **TODO-2 — dictionary artifacts.** New repo `developer/scrabble-dictionary` holds the word-list
sources + `cmd/builddict` and builds the three DAWGs against the **published solver + pinned
`dafsa`/`alphabet` v1.1.0**, so they are byte-identical to the solver's fixtures (no index drift).
Released as `scrabble-dawg-vX.Y.Z.tar.gz` (flat, one semver per set); the Go workflows download it
and point `BACKEND_DICT_DIR` at it. The runtime contract is unchanged (additive
`BACKEND_DICT_DIR/<version>/`, `engine.OpenWithVersions`, per-game `dict_version` pin; a version is
safe to retire once no active game pins it).
### Stage 15 — Dual Telegram bots & language-gated variants *(done)*
Re-framed at its start to be **service-agnostic**: the sign-in service returns, with the user identity, a
**set of supported game languages** (subset of `{en, ru}`, ≥ 1) that gates the New Game variant choice.
Built: the connector hosts **two bots in one container** (one per service language, each its own token +
game channel; the same Telegram user id spans both); `ValidateInitData` tries each token in turn and
returns the validating bot's **`service_language`** + **`supported_languages`** set. The set rides the
`Session` (FlatBuffers, session-scoped, not persisted) and the UI offers only the matching variants on New
Game (en → English; ru → Russian + Эрудит) — gating **only** the start of a new game (auto-match + friend
invite); existing games of any language are unrestricted and the backend does not enforce. The service
language is persisted (`accounts.service_language`, migration `00010`, written on every login —
last-login-wins) and routes the user-facing out-of-app push (`Notify`) back through the right bot (falls
back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the gateway default set
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
### Stage 16 — Deploy infra & test contour
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
gains **static UI serving****embedded** via `go:embed` (a node build stage in the gateway image),
SPA served at both `/` (web) and `/telegram/` (Mini App), the §13 single-origin model; prod UI build
vars (`VITE_TELEGRAM_BOT_ID`, `VITE_TELEGRAM_LINK`, `VITE_GATEWAY_URL`) as image build-args; a root
`deploy/docker-compose.yml` (backend + gateway + Postgres + connector + VPN sidecar + the **full
observability stack** — OTel Collector + Prometheus + Tempo + Grafana with provisioned dashboards) on
the external `edge` network behind the host caddy (VPN sidecar only for the connector); the backend
image pulls the DAWG release artifact (Stage 14). **The test contour deploys automatically on push to
a feature branch** (`docker compose up -d --build` on the local host where the gitea runner lives),
with a post-deploy probe (`GET /` on the gateway). Test-contour secrets use the **`TEST_`** prefix
(see Stage 16).
Open details (re-interview at start): the dashboard set; the gateway static-serving hook (before the
h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go build` works without a UI
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
collector/Tempo/Prometheus retention.
### Stage 17 — Prod contour deploy
Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
(`docker save``scp`/ssh → `docker load``docker compose up` on the remote), the SSH key + host IP
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after a feature branch is merged to
`master`. Two-contour config uses **`TEST_`/`PROD_` secret/variable prefixes** — Gitea 1.26 has no
deployment environments (verified: the `environments` API 404s), so a flat prefixed namespace is the
convention.
Open details (re-interview): export/import vs a registry trade-off; prod domain/TLS at the remote
caddy; prod VPN; rollback.
## Refinements logged during implementation
@@ -605,43 +703,384 @@ Open details: deployment target/host; dashboards; load expectations.
OS and can't be forced to match, and a select also avoids the iOS "clear" button
that would empty a time field.
- **Stage 9** (interview + implementation):
- **Connector as its own container** (interview): the Telegram side-service is a
standalone module `platform/telegram` (binary `cmd/telegram`) holding the bot
token **only there**; gateway and backend reach it by **unauthenticated gRPC**
on the trusted internal network, and it egresses to `api.telegram.org` through a
**VPN sidecar** (`deploy/docker-compose.yml`, mirroring `../15-puzzle`). Bot
library **`github.com/go-telegram/bot`** (one new dep), **long-poll** updates.
- **initData validation moved off the gateway** (interview): the gateway's HMAC
validator was **relocated** into the connector (`internal/initdata`, now also
returning `language_code`); the gateway calls `connector.ValidateInitData` over
gRPC during `auth.telegram`. The hop is negligible (loopback gRPC, once per
login). `GATEWAY_TELEGRAM_BOT_TOKEN` is gone; `GATEWAY_CONNECTOR_ADDR` replaces
it. The `gateway/internal/auth` package was deleted.
- **Connector gRPC API** (`pkg/proto/telegram/v1`, service `Telegram`): the
generic methods are **platform-agnostic**, keyed by the identity `external_id`
(so a future VK/MAX connector reuses them); only `ValidateInitData` is
Telegram-specific. Methods: `ValidateInitData`, **`Notify`** (the out-of-app push
— renders a localized message + a Mini App deep-link button from the FlatBuffers
payload), `SendToUser` and `SendToGameChannel` (arbitrary admin messages — built
and unit-tested now, **wired to the admin surface in Stage 10**; the game channel
id lives only in connector config).
- **Push = fallback, gateway-routed, de-dup by presence** (interview): the gateway
already consumes the firehose and knows in-app presence (`push.Hub.HasSubscribers`),
so it decides in-app vs out-of-app **atomically**: for a recipient with **no live
in-app stream** it fetches a new backend `/internal/push-target`
(`{external_id, language, notifications_in_app_only}`) and calls `connector.Notify`
only when they have a Telegram identity and have **not** set the new flag. Push
set: `your_turn`, `nudge`, `match_found`, and the `notify` sub-kinds `invitation`/
`friend_request` (the connector skips the rest). Delivery runs in a goroutine so a
slow connector never stalls the firehose; best-effort (no cursor resume — single
instance, §10).
- **Profile flag `notifications_in_app_only`** (interview, **default true** → push
is **opt-in**): migration `00007` (+ jetgen), threaded through
`account.Profile`/`UpdateProfile`, the REST DTOs, the fbs `Profile`/
`UpdateProfileRequest` (default `true` in the schema so an unset field reads
conservatively), and a Profile-screen toggle. Flagged at review: the channel is
silent until a user turns it off.
- **Language seeding from the platform** (discharges the Stage 8 forward-note):
`account.ProvisionTelegram` seeds a **brand-new** account's `preferred_language`
from the Telegram `language_code` and its display name from `first_name`/
`username` (existing accounts untouched); the UI's `adoptSession` already adopts
the server language when the user has not locked a locale, so no extra UI seeding
was needed. The gateway forwards the fields from `ValidateInitData`.
- **Mini App = `/telegram/` + guard** (interview): the gateway serves the one SPA
build under `/telegram/` (Vite **relative base**; the hash router is
path-agnostic). The UI detects a Telegram launch by `Telegram.WebApp.initData`,
applies `themeParams`, authenticates via the existing `auth.telegram` op (UI
`authTelegram` codec/client/transport/mock added), and routes the deep-link
`start_param` (`g`/`i`/`f` → game / lobby-invitation / friend-code redeem). On the
`/telegram/` path **without** initData it redirects to the site root. The official
`telegram-web-app.js` loads from `index.html` (harmless outside Telegram).
- **Deep-link scheme** (shared Go `platform/telegram/internal/deeplink` ↔ TS
`ui/src/lib/deeplink.ts`): `g<game uuid>` / `i<invitation uuid>` / `f<6-digit
code>` / empty = lobby. A friend-code **share-to-Telegram** link is shown when
`VITE_TELEGRAM_LINK` is configured (**partially discharges TODO-5**; QR still
open). The `Notify` button and the bot `/start` reply both wrap the payload as
`<MiniAppURL>?startapp=<payload>`.
- **Test environment** (interview nuance): the Bot API base is overridable for
Telegram's test environment — `TELEGRAM_TEST_ENV=true` suffixes the token with
`/test` so the client hits `/bot<token>/test/METHOD` (`TELEGRAM_API_BASE_URL`
overrides the host for a mock/self-hosted server).
- **Deploy groundwork** (interview): `platform/telegram/Dockerfile` (builds the
connector standalone — drops backend/gateway and the solver replace from a copy
of `go.work`, validated with `docker build`) + the connector-scoped compose with
the VPN sidecar; a root `.dockerignore`. **No public ingress** for the connector
(long-poll + sidecar egress); the host reverse proxy routes only to the gateway
port, which serves the Mini App. The full multi-service deploy is **Stage 12**.
- **Wire/codegen/CI**: new proto `pkg/proto/telegram/v1` (committed Go); fbs
`Profile`/`UpdateProfileRequest` gained `notifications_in_app_only` (committed Go
+ TS). `go.work` gains `use ./platform/telegram`; deps via `go mod edit` +
`go work sync` (no-tidy). `go-unit.yaml` gained the `platform/**` path filter and
builds/vets/tests `./platform/telegram/...`. UI grows to ~86 KB gzip JS (budget
100 KB). The connector's unit tests use an httptest fake Bot API; a Playwright
smoke drives the Mini App launch + guard with an injected `window.Telegram`.
- **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/
`SendToGameChannel` (backend gains its own connector client) for operator
broadcasts to a user and the game channel.
- **Verification-time fixes** (caught by the CI gate): (1) the gateway transcode
dropped `notifications_in_app_only` in four places (`ProfileResp`, `encodeProfile`,
`profileUpdateHandler`, the `UpdateProfile` body) so the toggle never reached the
backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made
**hermetic** (a shared `ui/e2e/fixtures.ts` blocks the real `telegram-web-app.js`):
the render-blocking CDN `<script>` hung every page load on the CI runner, where
telegram.org is unreachable, timing out all non-Telegram specs. (3) A pre-existing
time-of-day flake in `TestTimeoutSweep` (the default 00:0007:00 away window made
the sweeper skip when CI ran with `now-1h` inside it) was made deterministic by
clearing the test account's away window.
- **Stage 10** (interview + implementation):
- **Admin console = backend-rendered `/_gm`, gateway Basic-Auth** (interview, two
rounds): the owner chose a dedicated web console but, pointing at `../galaxy-game`
and asking to keep it simple, the deliverable is **server-rendered Go
`html/template` + one embedded CSS** (`backend/internal/adminconsole`: a
framework-agnostic renderer + page view-models, `//go:embed` templates/assets, zero
JS, no build step), **not** a SPA. It lives **in the backend** on its own route
`/_gm/*`; the **gateway** (the project's built-in reverse proxy) gates `/_gm/*` with
the existing `GATEWAY_ADMIN_USER/PASSWORD` Basic-Auth on its **public** listener and
proxies **verbatim** to backend `/_gm/*` (mounted on the edge mux below the h2c wrap
so Connect keeps working). This **supersedes Stage 6's** gateway-fronts-
`/api/v1/admin` model: the separate admin port `GATEWAY_ADMIN_ADDR` is dropped (only
the port — user/password stay), the backend `/api/v1/admin` group + `ping` are
removed, and `gateway/internal/admin` is repurposed to the verbatim proxy. The
backend keeps **no operator identity** and no `admin_accounts` table; CSRF on the
console's POSTs is a **same-origin** check (`Origin`/`Referer` vs `Host`, the gateway
preserves the inbound Host) — no token. Discharges Stage 1's "admin bootstrap" (it is
config, not a DB seed).
- **Complaint resolution + dictionary pipeline** (interview): migration **00008**
(+ jetgen) adds `disposition`/`resolution_note`/`resolved_at`/`applied_in_version`
to `complaints` and the deferred `status` CHECK (`open|resolved`) — **discharges
Stage 3's** deferral (no `resolved_by`: operator identity is not tracked). Resolution
sets a disposition (`reject`/`accept_add`/`accept_remove`); accepted complaints are
**derived by query** into a pending dictionary-change list (no new table), stamped
`applied_in_version` once a rebuilt version is loaded. New `game` reads
`ListComplaints`/`GetComplaint`/`CountComplaints`/`ResolveComplaint`/
`DictionaryChanges`/`MarkChangesApplied`; admin list/count reads
`account.ListAccounts/CountAccounts/Identities` and `game.ListGames/CountGames/
GameByID`.
- **Dictionary hot-reload = per-version subdir** (interview): the launch version stays
in the flat `BACKEND_DICT_DIR` (CI/dev untouched); a reloaded version `X` loads from
`BACKEND_DICT_DIR/X/` via the new `Registry.LoadAvailable` (present variants only),
and boot re-loads every subdirectory via `engine.OpenWithVersions` so reloaded
versions survive a restart. **Partially addresses TODO-2** (the runtime reload
contract; the offline DAWG generator stays future work).
- **Operator broadcasts** (discharges Stage 9's forward-note): the backend gains its
own connector gRPC client (`backend/internal/connector`, `BACKEND_CONNECTOR_ADDR`,
nil when unset) over the existing `pkg/proto/telegram/v1`; the console messages a
user by `account_id` (backend resolves the Telegram `external_id`) and posts to the
game channel via `SendToUser`/`SendToGameChannel`.
- **Config/CI**: backend adds `BACKEND_CONNECTOR_ADDR`; gateway drops
`GATEWAY_ADMIN_ADDR` (keeps user/password). No new module and no fbs/proto/UI codegen
(the console is server-rendered Go). The Go workflows already span
`./backend/... ./gateway/... ./pkg/...`; integration stays `./backend/...`.
- **Stage 11** (interview + implementation):
- **Scope = link-via-confirm + merge for email and Telegram** (interview): the
current account is the merge **primary**; a linked identity that already has its
own account is merged into the current one and the secondary is retired as an
**audit tombstone** (`accounts.merged_into`/`merged_at`, migration `00009`
+ jetgen). Linkable this stage: **email** (the existing confirm-code) and
**Telegram via the Login Widget** (the web sign-in). New `internal/accountmerge`
(the single-transaction data merge) and `internal/link` (the orchestrator over
account + accountmerge + session).
- **Tombstone, not delete** (interview): the secondary row is kept so a **shared
finished game**'s no-cascade `game_players`/`chat`/`complaints` foreign keys stay
valid; its seat in such a game is left in place. The merge is **refused**
(`ErrActiveGameConflict`) only when the two share an **active** game.
- **Merge algorithm** (one tx): stats summed (wins/losses/draws) + max kept;
`hint_balance` summed; identities repointed; non-shared `game_players` transferred
(shared kept); `chat_messages`/`complaints` reassigned; friendships/blocks repointed
with self-edge drop and dedupe (friendships by status precedence
accepted>pending>declined); invitations: secondary's as inviter deleted, invitee
rows deduped; secondary's `email_confirmations`/`friend_codes` dropped; secondary
tombstoned. Sessions are handled one layer up: `session.Service.RevokeAllForAccount`
(+ `Cache.RemoveByAccount`) retires the secondary's sessions after the tx.
- **Primary direction + guest inversion** (interview): primary = the current account,
**except** when the initiator is a **guest** and the linked identity already has a
**durable** owner — then the **durable account wins**, the guest's active games
transfer into it, the guest is retired, and a **fresh session for the durable
account is minted and returned** (the client adopts it). Binding a **free** identity
to a guest is a plain upgrade (clear `is_guest`, same session). Discharges Stage 8's
"guest email-binding is Stage 11".
- **API/UX = dedicated ops; reveal only after the code** (interview): new edge ops
`link.email.request/confirm/merge` (Email-rate-limited) and
`link.telegram.confirm/merge`. `request` **always** mails a code (no pre-send
"taken" signal, so a probe cannot enumerate registered addresses); a required merge
is revealed **only after** the code is verified, gating an explicit irreversible
merge step (the Profile screen's confirmation dialog). This **supersedes Stage 8's**
`email.bind.*` ops (and their fbs `EmailBindRequest`/`EmailConfirmRequest` tables),
which were retired from the gateway/UI for that reason; the backend
`EmailService.RequestCode`/`ConfirmCode` primitives stay (still covered by inttest).
- **Field policy** (interview): `display_name` = primary's; profile prefs/flags
(language, timezone, away window, block toggles, `notifications_in_app_only`) =
primary's; `hint_balance` = **sum**. A new service column **`paid_account`**
(`bool`, default false; lifetime one-time-payment marker, no purchase flow yet) is
added in `00009` and **ORed** on merge (`true` always wins). It is not user-editable
and is shown read-only on the admin account-detail page.
- **Telegram Login Widget** (interview, owner chose the broader scope): the connector
validates it (`internal/loginwidget`, secret = `SHA-256(bot_token)`, distinct from
initData) via a new `Telegram.ValidateLoginWidget` RPC; the gateway validates the
widget payload and passes the **trusted** `external_id` to the backend link route
(same trust model as `auth.telegram`). The UI offers "Link Telegram" only in a plain
web context (`loginWidgetAvailable`), driving the popup `Telegram.Login.auth`; it is
**inert in production until BotFather `/setdomain`** registers the site domain and
`VITE_TELEGRAM_BOT_ID` is configured (a deploy concern, Stage 12). e2e mocks the
widget (telegram.org is blocked on CI).
- **Wire/CI**: new fbs `LinkEmailRequest`/`LinkEmailConfirm`/`LinkTelegramRequest`/
`LinkResult` (committed Go + TS); new proto RPC (committed Go); new REST routes under
`/api/v1/user/link/*`. The Go workflows already span `./backend/... ./gateway/...
./pkg/... ./platform/telegram/...`; integration stays `./backend/...`. UI ~90 KB gzip
JS (budget 100 KB). New error code `merge_active_game_conflict`.
- **Stage 12** (interview + implementation):
- **Re-scoped & split** (interview): the original "Polish (observability + perf +
deploy)" was too large for one session, so it was split — **Stage 12** = observability
+ performance + guest GC; **Stage 13** = alphabet-on-the-wire (TODO-4); **Stage 14** =
CI & deploy (TODO-1, TODO-2, the collector + dashboards). The latter two were written
into the plan now as the agreed baseline (each still re-interviews at its own start).
(Stage 14 was itself later re-scoped to the solver/dictionary split alone; deploy +
observability + the dual-bot idea split into Stages 1517.)
- **Shared telemetry** (interview): a new `pkg/telemetry` owns the OTel provider
bootstrap (exporter selection, W3C propagators, shutdown, Go runtime metrics); the
backend `internal/telemetry` is now a thin facade over it (keeping its gin middleware),
and the gateway and connector gained telemetry runtimes. A configurable **`otlp`**
exporter was added alongside `none`/`stdout`; the **default stays `none`**, the OTLP
endpoint comes from the standard `OTEL_EXPORTER_OTLP_*` env, and the collector +
dashboards are Stage 15 (so CI needs none). `otelgrpc` instruments the backend push
server, the gateway's backend + connector clients, and the connector's gRPC server.
New config `GATEWAY_SERVICE_NAME`/`GATEWAY_OTEL_*` and `TELEGRAM_SERVICE_NAME`/
`TELEGRAM_OTEL_*`; the backend's existing `BACKEND_OTEL_*` gained the `otlp` value.
- **Metrics = operational, business-near** (interview): histograms
`game_replay_duration` and `game_move_validate_duration`; counters
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop) and
`chat_messages_total` (`kind`=message/nudge); an observable gauge `game_cache_active`;
the gateway `edge_request_duration` (`message_type`/`result`); plus Go runtime/heap
metrics. Game-scoped metrics carry a **`variant`** attribute
(english/russian_scrabble/erudit — chosen over a coarser `language`, which it
subsumes); the gateway edge metric is variant-agnostic. Optional wiring uses the
established `SetMetrics`/`SetNotifier` setter pattern (default no-op meter), so existing
constructors and tests are untouched. **No speculative optimisation** — there is no
measured hotspot; the deliverable is the instrumentation (the standing "performance only
with evidence" rule). pprof was not added (reframed away by the owner).
- **Guest GC** (interview, TODO-3): age-based, no-seat-only — see the discharged TODO-3
below; new config `BACKEND_GUEST_REAP_INTERVAL`/`BACKEND_GUEST_RETENTION`.
- **Deps/CI**: new OTel modules (the OTLP exporters,
`contrib/instrumentation/runtime`, `otelgrpc`) added with the no-tidy pattern
(`go mod edit` + `go mod download` + `go work sync`; `pkg` carries no bare-path dep, so
it tidies cleanly). No workflow change — the Go workflows already span
`./backend/... ./gateway/... ./pkg/... ./platform/telegram/...`, integration stays
`./backend/...`, and the default `none` exporter keeps CI collector-free.
- **Stage 13** (interview + implementation, discharges TODO-4):
- **Scope = live play only** (interview): indices ride the wire for `StateView.rack`
(out) and `SubmitPlay`/`Evaluate`/`Exchange`/`CheckWord` (in). The **board path is
untouched** — `MoveRecord` (history, move results, hint), formed `words`,
`ComplaintRequest.word` (durable, admin-reviewed) and `WordCheckResult.word` (echo) stay
decoded concrete characters, so the durable journal / GCG and the §9.1 invariant are
unchanged. **Hard cutover**, no dual letter/index fields (single client; the fbs Go + TS
regenerate in one PR; no external wire consumer). Exchange moved fully to indices, a
blank = the shared sentinel index **255** (`engine.BlankIndex`).
- **Edge-mapping layering** (engineering): the engine gained a cached per-variant codec —
`AlphabetTable` (the `(index, letter, value)` table from the solver ruleset),
`LetterForIndex`, `EncodeRack`, `DecodeTiles`, `DecodeWord` — and the backend **server
edge** owns the index↔letter mapping. `game.Service`'s domain methods, `engine.Game` and
the **robot** keep a single **letter-based** play path (untouched); a new thin
`game.Service.GameVariant` (a single-column `SELECT variant`, cheaper than `GetGame`)
lets the inbound handlers resolve the variant without doubling the play-path read. The
**gateway carries no alphabet table** — it passes indices through verbatim; `check_word`
rides as repeated `?idx=` query params.
- **`include_alphabet` flag** (interview): `StateRequest.include_alphabet` gates the table
so it is not resent on every poll; the client sets it only on a **per-variant cache
miss** (first open of a variant), and the table then arrives with the index rack so the
rack is always decodable. The client caches the table in memory by variant
(`ui/src/lib/alphabet.ts`).
- **Letter case** (discovered): the solver emits **lower-case** letters and the rest of
the UI works in **upper case**. The wire and the journal stay lower case; the **UI
normalises display to upper case** (the codec upper-cases decoded board tiles and words,
and the alphabet cache upper-cases on ingest), so `placement.ts` / `board.ts` /
`checkword.ts` are unchanged and the latent real-backend lower-case display is fixed.
- **Parity rework** (interview): the real value/alphabet parity moved to a **Go engine
test** (`engine.AlphabetTable`: EN/RU/Эрудит sizes, EN a=1/q=10, **Эрудит ё=index 6,
value 0**); `ui/src/lib/premiums.ts` is now **geometry only** (its value tables,
`tileValue` and `alphabet` were removed, its parity test trimmed to the premium grid);
the codec test round-trips the index tiles + the alphabet table; the **mock keeps a
fixture table** (relocated from `premiums.ts`) seeded into the client cache, so the
mock-driven UI is alphabet-agnostic too.
- **Wire/codegen/CI**: new fbs `AlphabetEntry` + `PlayTile`; `StateView.rack`→`[ubyte]` +
`alphabet`; `StateRequest.include_alphabet`; `SubmitPlay`/`Eval` tiles→`[PlayTile]`;
`Exchange` tiles→`[ubyte]`; `CheckWord.word`→`[ubyte]` (committed Go + TS regenerated).
UI ~90 KB gzip JS (budget 100 KB). **No CI workflow change** — the Go workflows already
span `./backend/... ./gateway/... ./pkg/...` and the UI workflow runs check/unit/build +
a chromium/webkit e2e. `docs/FUNCTIONAL.md` is **untouched** (no user-visible behaviour
change — the UI looks and plays the same; like Stage 2). The index-drift caveat is
handled by construction (the running backend produces the table, so client↔server cannot
drift); the DAWG/solver build-time agreement remains **Stage 14 / TODO-2**.
- **Stage 14** (interview + implementation, re-scoped + discharges TODO-1/TODO-2):
- **Re-scoped to the split** (interview): the original "CI & deploy" was several sessions of work,
so it was cut to the **solver/dictionary split** (the dependency foundation) and the deploy +
observability + the dual-bot idea were written into the plan as new **Stages 1517**. The deploy
decisions taken at the interview are recorded there (embed the UI in the gateway via `go:embed`;
full Collector+Prometheus+Tempo+Grafana stack; **two contours** — test = auto on feature-branch
push on the local host, prod = manual SSH `docker save`/`load` after merge; `TEST_`/`PROD_` secret
prefixes since Gitea 1.26 has no environments — verified).
- **TODO-1 — publish solver** (interview: "опубликовать и запинить"): `scrabble-solver` renamed to
module `gitea.iliadenisov.ru/developer/scrabble-solver`, `internal/{wordlist,dictdawg}`
**de-internalised** to public packages (so the dict repo imports one builder — no drift), the build
pipeline (`cmd/builddict`, `dictprep`, the `dictionaries` submodule) moved out, `internal/dict`
repointed at the committed `dawg/*.dawg` fixtures, tagged **v1.0.0**. scrabble-game pins it in
`backend/go.mod`, drops the `go.work` replace + the CI clone, and sets `GOPRIVATE=gitea.iliadenisov.ru/*`
(go fetches the module directly from Gitea — verified end-to-end). The solver hash lives in
`go.work.sum` (workspace mode; the bare-path `scrabble/pkg` replace still blocks `go mod tidy`).
- **TODO-2 — dictionary repo** (interview: "полный TODO-2, новый репо"): `developer/scrabble-dictionary`
builds the three DAWGs against the published solver + pinned `dafsa`/`alphabet` v1.1.0,
**byte-identical** to the solver fixtures; published as the release artifact
`scrabble-dawg-v1.0.0.tar.gz`; both Go workflows download it for `BACKEND_DICT_DIR` instead of
cloning the solver. English source vendored from `kamilmielnik/scrabble-dictionaries`; the Эрудит
fold is committed as `dictprep/russian/erudit.txt`, so the build needs no `python`.
- **Bootstrap nuances** (encountered): the dict repo was created empty with a protected `master`, so
it was seeded once via an owner-authorised protection lift→push→restore (a subsequent CI-fix push
correctly went through a PR, not another lift); it was made **public** (like the solver) so the Go
workflows fetch the artifact anonymously. Its CI is a **build-only** validation gate — the
auto-release step's `${{ github.* }}` contexts failed the Gitea workflow compile, so releases are
published manually for now (a logged follow-up).
- **Stage 15** (interview + implementation):
- **Re-framed service-agnostic** (interview): the owner kept the two-bots-in-one-container model but
generalised the language signal — the sign-in service returns a **set** of supported game languages
(subset of `{en, ru}`, ≥ 1) on the validate response, and the **UI gates** the New Game variant choice
by it. Two distinct scopes, deliberately not conflated: the **gating set** is per-session (rides the
`Session` fbs, never persisted — so the same `telegram_id` logged in through the en- and ru-bot gates
differently, which is correct), and the **routing language** is per-account.
- **Push routing resolved** (interview, the original "which bot delivers" open detail): only the
**user-facing `Notify`** carries the `en`/`ru` language from the user's **last `ValidateInitData`**,
persisted as `accounts.service_language` (migration `00010`, written every login — new and existing —
last-login-wins, read by `/internal/push-target` with a `preferred_language` fallback). It is NOT the
game's variant language. **Correction mid-interview:** the admin broadcasts `SendToUser` /
`SendToGameChannel` are admin-panel-only and unrelated to `ValidateInitData`; they pick the bot by an
**operator-chosen** language (a console `<select>`), so a `language` field was added to those two RPCs
sourced from the form, not from `service_language`.
- **Gating = UI-only, creation-only** (interview): the backend does not enforce (a valid game is
harmless, not a trust boundary); only the New Game pickers (auto-match + friend invite) filter — there
is no variant picker on accept/open/play, so those are inherently ungated. Non-Telegram logins
(web/email/guest) carry the gateway default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, default all).
- **Wire/connector**: `ValidateInitDataResponse` gained `service_language` + `supported_languages`; the
fbs `Session` gained `supported_languages:[string]`; `SendToUser`/`SendToGameChannel` gained
`language` (committed Go + TS regenerated via `make -C pkg gen` + `pnpm -C ui codegen`). The connector
config moved to **per-language** bots (`TELEGRAM_BOT_TOKEN_EN/_RU`, `TELEGRAM_GAME_CHANNEL_ID_EN/_RU`;
`TELEGRAM_MINIAPP_URL` shared; ≥ 1 token required — a breaking config change, no prod yet); the
server hosts a bot map and routes by language. The push template language now follows the routing bot
(was `preferred_language`) — a documented change. The deploy compose/Dockerfile env was updated to the
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
already span the touched modules).
## Deferred TODOs (cross-stage)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
give it a real module URL and switch `backend` to a versioned dependency,
dropping the `go.work` replace and the CI clone. Removes the floating
`master` dependency accepted for now (Stage 2 interview).
- **TODO-2 — split the solver into engine vs dictionary generator + versioned
dictionary artifacts.** Owner's idea, with the caveats agreed at the Stage 2
interview: the split is sound (build-time wordlist→DAWG vs runtime load have
different lifecycles and shrink the runtime dependency surface), **but** the
generator must pin the **same** `dafsa`/`alphabet` versions and alphabet
definitions as the runtime engine or the on-disk format / letter indexing
drifts and silently corrupts validation. For delivery prefer **Git LFS or an
artifact store** (Gitea releases / OCI artifact / object storage) over a raw
git submodule (the ~0.50.7 MB DAWGs are regenerated wholesale and bloat git
history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull
is a **deploy-time** way to populate the directory, **not** the runtime
dynamic-reload mechanism (Stage 10) — keep the `BACKEND_DICT_DIR` directory as
the runtime contract: a new `.dawg` appears in it and is loaded with
`dawg.Load`.
- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a
durable `accounts` row (no identity, `is_guest`), so an ephemeral guest leaves a
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
guest accounts with no active games once their last session is gone; the
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
letters. Consider extending `game.state` to carry the variant's `(letter, index,
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
- **TODO-5 — QR / deep-link friend codes (owner's idea, Stage 8).** The one-time
friend code is entered by hand today. Once the Telegram/native deep-link scheme
exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can
add you by scanning rather than typing. The code semantics (12 h TTL, single use,
one active per issuer) stay as-is; only the delivery changes.
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
published as module `gitea.iliadenisov.ru/developer/scrabble-solver` (tagged `v1.0.0`, with
`wordlist`/`dictdawg` de-internalised to public packages); `backend/go.mod` pins it, the `go.work`
replace and the CI sibling-clone are gone, and `GOPRIVATE=gitea.iliadenisov.ru/*` fetches it directly
(no public proxy/checksum DB). Removes the floating `master` dependency accepted since Stage 2.
- ~~**TODO-2 — split the solver into engine vs dictionary generator + versioned dictionary
artifacts.**~~ **Done in Stage 14.** A new repo `developer/scrabble-dictionary` holds the word-list
sources + `cmd/builddict` (moved out of the solver, with `dictprep` and the `dictionaries` submodule)
and builds the three DAWGs against the **published solver + pinned `dafsa`/`alphabet` v1.1.0** — the
output is **byte-identical** to the solver's committed fixtures, so the index-drift caveat is handled
by construction. Delivered as a Gitea **release artifact** `scrabble-dawg-vX.Y.Z.tar.gz` (not
`go get`; DAWGs are data; **one semver label for the whole set**); the Go workflows download it for
`BACKEND_DICT_DIR`. The runtime dynamic-reload contract (per-version `BACKEND_DICT_DIR/<version>/` via
`Registry.LoadAvailable` / `engine.OpenWithVersions`, Stage 10) is unchanged — a deploy drops a new
set into the directory; a version is safe to retire once no active game pins it.
- ~~**TODO-3 — garbage-collect abandoned guest accounts.**~~ **Done in Stage 12.**
A periodic `account.GuestReaper` deletes guests (`is_guest`) **with no game seat at
all** whose account age exceeds `BACKEND_GUEST_RETENTION` (default 30 d, swept every
`BACKEND_GUEST_REAP_INTERVAL`, default 1 h). Two schema facts shaped this, narrowing
the original sketch: (1) `game_players`/`chat_messages`/`complaints` reference accounts
**without** `ON DELETE CASCADE`, and a finished game belongs to the other players'
history, so a guest with any seat is retained (a delete would be blocked anyway) — hence
"no seat", not "no active game"; (2) sessions are revoke-only with no maintained
`last_seen_at`, so a lingering session never expires and **account age** is the
abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/
`account_stats` fall away via their own `ON DELETE CASCADE`.
- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in
Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter,
value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant
cache miss — and live play exchanges **letter indices** both ways (rack, submit-play,
evaluate, exchange, word-check; a blank rides as the sentinel index 255). The table is
produced from the solver ruleset (`engine.AlphabetTable`), so it is pinned by the solver
version and cannot drift from the running backend, and `ui/src/lib/premiums.ts` is now
geometry only. The durable journal / history / GCG stay decoded concrete characters (§9.1,
unchanged). The DAWG/solver build-time agreement (the original caveat, shared with TODO-2) was
discharged in Stage 14: the dict repo builds against the published solver + pinned
`dafsa`/`alphabet`, byte-identical to the fixtures.
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
launch, and the UI shows a **share-to-Telegram** link for an issued code when
`VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a
friend can add you by scanning rather than tapping/typing. The code semantics
(12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes.
- **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).**
The play-with-friends form has no preselected variant today (an empty, required
pick). Default it from the player's history (the variant they play most, from
+62 -24
View File
@@ -67,10 +67,37 @@ list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
`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.
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. The shared wire contracts live in the
sibling [`../pkg`](../pkg) module.
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`) — each
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
migration `00010` (`accounts.service_language`): 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`
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
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
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.
Migration `00009` adds `paid_account`/`merged_into`/`merged_at`. This supersedes the
Stage 8 `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
## Package layout
@@ -82,14 +109,18 @@ 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/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/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
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)
@@ -105,8 +136,8 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
| `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_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
| `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. |
@@ -119,13 +150,19 @@ internal/robot/ # human-like robot opponent: account pool, seed-derived str
| `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. |
| `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. |
## Run
```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.0.0/scrabble-dawg-v1.0.0.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=../../scrabble-solver/dawg \
BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \
go run ./cmd/backend
```
@@ -148,19 +185,18 @@ go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp containe
## 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`](../PLAN.md) TODO-2.
`internal/engine` consumes `scrabble-solver` in-process as a **published, versioned
module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `go.mod`). Set
`GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea (skipping
the public proxy/checksum DB); no sibling checkout or `go.work` replace is needed (for
local solver co-development you may add a temporary replace — see `go.work`).
`github.com/iliadenisov/dafsa` (the DAWG loader) is a direct dependency. The dictionaries
(`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).
## Tests
@@ -171,6 +207,8 @@ 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.
rather than skipping. The `internal/engine` tests load the DAWGs from
`BACKEND_DICT_DIR` (CI sets it to the extracted dictionary release artifact; locally it
defaults to a `scrabble-solver/dawg` sibling checkout) and fail loudly when that directory
is absent. `GOPRIVATE=gitea.iliadenisov.ru/*` is needed for go to fetch the pinned solver
module.
+37 -1
View File
@@ -18,9 +18,12 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/config"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres"
@@ -77,6 +80,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Warn("telemetry shutdown", zap.Error(err))
}
}()
if err := tel.StartRuntimeMetrics(); err != nil {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
db, err := postgres.Open(ctx, cfg.Postgres,
postgres.WithTracerProvider(tel.TracerProvider()),
@@ -92,7 +98,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("database migrations applied")
registry, err := engine.Open(cfg.Game.DictDir, cfg.Game.DictVersion)
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
if err != nil {
return fmt.Errorf("load dictionaries: %w", err)
}
@@ -101,6 +107,19 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
zap.String("dir", cfg.Game.DictDir),
zap.String("version", cfg.Game.DictVersion))
// Stage 10 admin console: an optional backend client to the Telegram connector
// side-service for operator broadcasts. Unset (BACKEND_CONNECTOR_ADDR empty)
// leaves broadcasts disabled — the console shows a "not configured" notice.
var conn *connector.Client
if cfg.ConnectorAddr != "" {
conn, err = connector.New(cfg.ConnectorAddr)
if err != nil {
return fmt.Errorf("dial connector: %w", err)
}
defer func() { _ = conn.Close() }()
logger.Info("connector client ready", zap.String("addr", cfg.ConnectorAddr))
}
sessions := session.NewService(session.NewStore(db), session.NewCache())
if err := sessions.Warm(ctx); err != nil {
return fmt.Errorf("warm session cache: %w", err)
@@ -115,17 +134,30 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
accounts := account.NewStore(db)
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
games.SetNotifier(hub)
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
// Stage 12 TODO-3: reap abandoned guest accounts (no game seat, account age past
// the retention window). Dependent rows fall away via ON DELETE CASCADE.
guestReaper := account.NewGuestReaper(accounts, cfg.GuestRetention, logger)
go guestReaper.Run(ctx, cfg.GuestReapInterval)
logger.Info("guest reaper started",
zap.Duration("interval", cfg.GuestReapInterval),
zap.Duration("retention", cfg.GuestRetention))
// Stage 4 lobby & social domains. Their REST and stream surface is added with
// the gateway in Stage 6, so they are handed to the server (like the route
// groups) for the handlers to come.
mailer := newMailer(cfg.SMTP, logger)
emails := account.NewEmailService(accounts, mailer)
// Stage 11 account linking & merge: the orchestrator over the account, merge and
// session layers. Wired to the /api/v1/user/link REST surface below.
links := link.NewService(emails, accounts, accountmerge.NewMerger(db), sessions)
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// Stage 5 robot opponent: provision its durable account pool (a hard startup
// dependency, like the dictionaries) and start its move driver. The matchmaker
@@ -156,6 +188,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Matchmaker: matchmaker,
Invitations: invitations,
Emails: emails,
Links: links,
Registry: registry,
DictDir: cfg.Game.DictDir,
Connector: conn,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
+2 -1
View File
@@ -3,6 +3,7 @@ module scrabble/backend
go 1.26.3
require (
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0
github.com/XSAM/otelsql v0.42.0
github.com/gin-gonic/gin v1.12.0
github.com/go-jet/jet/v2 v2.14.1
@@ -20,7 +21,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/zap v1.27.1
scrabble-solver v0.0.0-00010101000000-000000000000
)
require (
@@ -101,6 +101,7 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
+191 -6
View File
@@ -10,7 +10,9 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
@@ -54,13 +56,41 @@ type Account struct {
HintBalance int
BlockChat bool
BlockFriendRequests bool
// ServiceLanguage is the language tag (en/ru) of the bot the account last
// authenticated through (its last Telegram ValidateInitData); it routes the
// account's out-of-app push back through the right bot. Empty when the account
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the
// interface language) and from a game's variant language.
ServiceLanguage string
// IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history.
IsGuest bool
// NotificationsInAppOnly confines notifications to the in-app live stream when
// true (the default): the platform side-service skips out-of-app push for the
// account (Stage 9).
NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated (Stage 11).
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid (Stage 11).
MergedInto uuid.UUID
CreatedAt time.Time
UpdatedAt time.Time
}
// Identity is one of an account's platform/email identities, surfaced on the
// admin account-detail view. ExternalID is the platform user id (or the email
// address for an email identity); Confirmed tracks the email confirm-code flow.
type Identity struct {
Kind string
ExternalID string
Confirmed bool
CreatedAt time.Time
}
// Store is the Postgres-backed query surface for accounts and identities.
type Store struct {
db *sql.DB
@@ -77,6 +107,22 @@ func NewStore(db *sql.DB) *Store {
// resolved by re-reading the winner's account. A platform identity is recorded
// as confirmed; an email identity starts unconfirmed.
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
return s.provision(ctx, kind, externalID, provisionSeed{})
}
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
// identity. On first contact only, it seeds the new account's preferred language
// from the Telegram client languageCode (when it maps to a supported language) and
// its display name from firstName (falling back to username); an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
}
// provision finds the account for (kind, externalID) or creates it with seed,
// collapsing a concurrent-create race on the identity unique constraint into a
// re-read of the winner's account.
func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if err == nil {
return acc, nil
@@ -85,7 +131,7 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return Account{}, err
}
acc, err = s.create(ctx, kind, externalID)
acc, err = s.create(ctx, kind, externalID, seed)
if err != nil {
if isUniqueViolation(err) {
// A concurrent caller created the identity first; return theirs.
@@ -96,6 +142,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return acc, nil
}
// provisionSeed carries the optional create-time profile seed for a brand-new
// account (Telegram first contact). Empty fields fall back to the accounts table
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
// the ” default.
type provisionSeed struct {
preferredLanguage string
displayName string
}
// telegramSeed derives the create-time seed from Telegram launch fields: a
// supported preferred language from languageCode (an ISO-639 code, possibly
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
// username (capped to maxDisplayName runes).
func telegramSeed(languageCode, username, firstName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
seed.preferredLanguage = lang
}
name := strings.TrimSpace(firstName)
if name == "" {
name = strings.TrimSpace(username)
}
if utf8.RuneCountInString(name) > maxDisplayName {
name = string([]rune(name)[:maxDisplayName])
}
seed.displayName = name
return seed
}
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
@@ -113,6 +188,77 @@ func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
return modelToAccount(row), nil
}
// IdentityExternalID returns the external_id of the account's identity of the
// given kind, or ErrNotFound when the account has no such identity. The Telegram
// side-service uses it (through the gateway push-target lookup) to address an
// out-of-app notification to a recipient's Telegram chat.
func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) {
stmt := postgres.SELECT(table.Identities.ExternalID).
FROM(table.Identities).
WHERE(
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Identities.Kind.EQ(postgres.String(kind))),
).
LIMIT(1)
var row model.Identities
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return "", ErrNotFound
}
return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err)
}
return row.ExternalID, nil
}
// Identities returns the account's platform/email identities, oldest first, for
// the admin account-detail view.
func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity, error) {
stmt := postgres.SELECT(table.Identities.AllColumns).
FROM(table.Identities).
WHERE(table.Identities.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.Identities.CreatedAt.ASC())
var rows []model.Identities
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: list identities %s: %w", accountID, err)
}
out := make([]Identity, 0, len(rows))
for _, r := range rows {
out = append(out, Identity{Kind: r.Kind, ExternalID: r.ExternalID, Confirmed: r.Confirmed, CreatedAt: r.CreatedAt})
}
return out, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
FROM(table.Accounts).
ORDER_BY(table.Accounts.CreatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Accounts
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("account: list accounts: %w", err)
}
out := make([]Account, 0, len(rows))
for _, r := range rows {
out = append(out, modelToAccount(r))
}
return out, nil
}
// CountAccounts returns the total number of accounts, for admin-list pagination.
func (s *Store) CountAccounts(ctx context.Context) (int, error) {
stmt := postgres.SELECT(postgres.COUNT(table.Accounts.AccountID).AS("count")).
FROM(table.Accounts)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("account: count accounts: %w", err)
}
return int(dest.Count), nil
}
// findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
@@ -137,9 +283,9 @@ func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Ac
return modelToAccount(row), nil
}
// create inserts a new account and its first identity inside one transaction
// and returns the persisted account row.
func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) {
// create inserts a new account (seeded from seed) and its first identity inside
// one transaction and returns the persisted account row.
func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new account id: %w", err)
@@ -151,9 +297,16 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e
var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Seed the new row's display name and language (Telegram first contact); an
// empty seed reproduces the table defaults ('' and 'en') the other callers
// relied on, so their behaviour is unchanged.
lang := seed.preferredLanguage
if lang == "" {
lang = "en"
}
insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID).
VALUES(accountID).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
VALUES(accountID, seed.displayName, lang).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -227,12 +380,41 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
return n > 0, nil
}
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
// user authenticated through. It is called on every Telegram login — new and
// existing accounts — so it tracks the bot the user last came through (last-login-
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
// (a non-Telegram login carries none) and does not bump updated_at (an infra
// routing field, not a user profile edit).
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
if language == "" {
return nil
}
stmt := table.Accounts.
UPDATE(table.Accounts.ServiceLanguage).
SET(postgres.String(language)).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: set service language %s: %w", id, err)
}
return nil
}
// modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account {
var mergedInto uuid.UUID
if row.MergedInto != nil {
mergedInto = *row.MergedInto
}
var serviceLanguage string
if row.ServiceLanguage != nil {
serviceLanguage = *row.ServiceLanguage
}
return Account{
ID: row.AccountID,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
ServiceLanguage: serviceLanguage,
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
@@ -240,6 +422,9 @@ func modelToAccount(row model.Accounts) Account {
BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
+145
View File
@@ -0,0 +1,145 @@
package account
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// ErrIdentityTaken is returned when a platform identity being linked already
// belongs to another account; the caller turns it into a merge (Stage 11).
var ErrIdentityTaken = errors.New("account: identity already linked to another account")
// RequestLinkCode issues and mails a confirm-code for email to accountID,
// replacing any prior pending code. Unlike RequestCode it never refuses up front
// (taken or already-confirmed): possession of the address is the authorization for
// a later link or merge, and the merge is only revealed once the code is verified,
// so a probe cannot learn whether an address is registered (Stage 11).
func (s *EmailService) RequestLinkCode(ctx context.Context, accountID uuid.UUID, email string) error {
addr, err := normalizeEmail(email)
if err != nil {
return err
}
code, hash, err := generateCode()
if err != nil {
return err
}
if err := s.store.replacePendingConfirmation(ctx, accountID, addr, hash, s.now().Add(emailCodeTTL)); err != nil {
return err
}
subject := "Your Scrabble confirmation code"
body := fmt.Sprintf("Your confirmation code is %s. It expires in %d minutes.", code, int(emailCodeTTL/time.Minute))
return s.mailer.Send(ctx, addr, subject, body)
}
// ConfirmLink verifies code for (accountID, email) and reports the address's
// current owner. When the address is free it binds a confirmed email identity to
// accountID and returns (accountID, true, nil). When accountID already owns it,
// it returns (accountID, true, nil) unchanged. When another account owns it, it
// returns (owner, false, nil) without consuming the code, so the explicit merge
// step can re-verify the same live code. It returns the usual confirm-code errors
// (ErrNoPendingCode, ErrCodeExpired, ErrTooManyAttempts, ErrCodeMismatch).
func (s *EmailService) ConfirmLink(ctx context.Context, accountID uuid.UUID, email, code string) (uuid.UUID, bool, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.Nil, false, err
}
conf, err := s.verifyPendingCode(ctx, accountID, addr, code)
if err != nil {
return uuid.Nil, false, err
}
owner, ok, err := s.store.confirmedEmailAccount(ctx, addr)
if err != nil {
return uuid.Nil, false, err
}
if ok {
if owner == accountID {
return accountID, true, nil
}
return owner, false, nil
}
if err := s.store.confirmEmailIdentity(ctx, conf.id, accountID, addr, s.now()); err != nil {
return uuid.Nil, false, err
}
return accountID, true, nil
}
// verifyPendingCode loads and checks the pending confirm-code for (accountID,
// addr), counting a wrong attempt. It returns the confirmation on success.
func (s *EmailService) verifyPendingCode(ctx context.Context, accountID uuid.UUID, addr, code string) (emailConfirmation, error) {
conf, err := s.store.latestPendingConfirmation(ctx, accountID, addr)
if err != nil {
return emailConfirmation{}, err
}
if s.now().After(conf.expiresAt) {
return emailConfirmation{}, ErrCodeExpired
}
if conf.attempts >= emailCodeMaxAttempts {
return emailConfirmation{}, ErrTooManyAttempts
}
if hashCode(code) != conf.codeHash {
if err := s.store.bumpConfirmationAttempts(ctx, conf.id); err != nil {
return emailConfirmation{}, err
}
return emailConfirmation{}, ErrCodeMismatch
}
return conf, nil
}
// AccountIDByIdentity returns the account owning (kind, externalID) and true, or
// (uuid.Nil, false) when the identity is free. It backs the platform-identity link
// flow (Stage 11).
func (s *Store) AccountIDByIdentity(ctx context.Context, kind, externalID string) (uuid.UUID, bool, error) {
acc, err := s.findByIdentity(ctx, kind, externalID)
if errors.Is(err, ErrNotFound) {
return uuid.Nil, false, nil
}
if err != nil {
return uuid.Nil, false, err
}
return acc.ID, true, nil
}
// AttachIdentity links a new (kind, externalID) identity to an existing account.
// A unique-constraint violation means the identity was taken meanwhile, surfaced
// as ErrIdentityTaken. It is used to attach a platform identity (e.g. Telegram)
// to the current account during linking (Stage 11).
func (s *Store) AttachIdentity(ctx context.Context, accountID uuid.UUID, kind, externalID string, confirmed bool) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("account: new identity id: %w", err)
}
ins := table.Identities.INSERT(
table.Identities.IdentityID, table.Identities.AccountID, table.Identities.Kind,
table.Identities.ExternalID, table.Identities.Confirmed,
).VALUES(id, accountID, kind, externalID, confirmed)
if _, err := ins.ExecContext(ctx, s.db); err != nil {
if isUniqueViolation(err) {
return ErrIdentityTaken
}
return fmt.Errorf("account: attach identity (%s, %s): %w", kind, externalID, err)
}
return nil
}
// ClearGuest removes the is_guest flag from accountID, promoting an ephemeral guest
// to a durable account once it gains its first identity (Stage 11). It is a no-op
// for an already-durable account.
func (s *Store) ClearGuest(ctx context.Context, accountID uuid.UUID) error {
upd := table.Accounts.UPDATE(table.Accounts.IsGuest, table.Accounts.UpdatedAt).
SET(postgres.Bool(false), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Accounts.IsGuest.EQ(postgres.Bool(true))),
)
if _, err := upd.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: clear guest %s: %w", accountID, err)
}
return nil
}
+5 -2
View File
@@ -46,6 +46,7 @@ type ProfileUpdate struct {
AwayEnd time.Time
BlockChat bool
BlockFriendRequests bool
NotificationsInAppOnly bool
}
// UpdateProfile validates and overwrites the editable fields of the account, then
@@ -71,11 +72,13 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.TimestampzT(time.Now().UTC()),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
@@ -0,0 +1,48 @@
package account
import (
"strings"
"testing"
"unicode/utf8"
)
// TestTelegramSeed covers the pure mapping from Telegram launch fields to the
// create-time account seed: supported-language detection (bare and region-tagged),
// the first-name / username display-name precedence, and trimming.
func TestTelegramSeed(t *testing.T) {
cases := map[string]struct {
languageCode, username, firstName string
wantLang, wantName string
}{
"ru bare": {"ru", "user", "Иван", "ru", "Иван"},
"en region-tagged": {"en-US", "user", "John", "en", "John"},
"ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"},
"unknown language": {"fr", "frodo", "Frodo", "", "Frodo"},
"empty language": {"", "neo", "Neo", "", "Neo"},
"first name wins": {"en", "handle", "Real Name", "en", "Real Name"},
"username fallback": {"en", "handle", "", "en", "handle"},
"both empty": {"en", "", "", "en", ""},
"trimmed": {" RU ", " ", " Anna ", "ru", "Anna"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := telegramSeed(tc.languageCode, tc.username, tc.firstName)
if got.preferredLanguage != tc.wantLang {
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
}
if got.displayName != tc.wantName {
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
}
})
}
}
// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to
// maxDisplayName runes (counted in runes, not bytes).
func TestTelegramSeedTruncatesLongName(t *testing.T) {
long := strings.Repeat("я", maxDisplayName+5)
got := telegramSeed("ru", "", long)
if n := utf8.RuneCountInString(got.displayName); n != maxDisplayName {
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
}
}
+87
View File
@@ -0,0 +1,87 @@
package account
import (
"context"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"go.uber.org/zap"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// ReapAbandonedGuests deletes guest accounts created before olderThan that are
// not seated in any game. It returns the number deleted.
//
// Scope is deliberately "no game seat at all", not merely "no active game": a
// finished game belongs to the other players' history, and game_players carries no
// ON DELETE CASCADE to accounts (docs/ARCHITECTURE.md §4), so a guest with any seat
// is retained (and a delete would be blocked by that foreign key regardless). The
// dependent rows of a reaped guest — sessions, identities, account_stats — fall
// away through their own ON DELETE CASCADE foreign keys. Account age is the
// abandonment signal because sessions are revoke-only with no maintained
// last_seen_at, so a lingering session never expires on its own.
func (s *Store) ReapAbandonedGuests(ctx context.Context, olderThan time.Time) (int64, error) {
stmt := table.Accounts.DELETE().WHERE(
table.Accounts.IsGuest.EQ(postgres.Bool(true)).
AND(table.Accounts.CreatedAt.LT(postgres.TimestampzT(olderThan))).
AND(postgres.NOT(postgres.EXISTS(
postgres.SELECT(table.GamePlayers.AccountID).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.AccountID.EQ(table.Accounts.AccountID)),
))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("account: reap guests: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("account: reap guests rows affected: %w", err)
}
return n, nil
}
// GuestReaper periodically deletes abandoned guest accounts via
// Store.ReapAbandonedGuests. It mirrors the game turn-timeout sweeper and the
// matchmaker reaper: one background goroutine, started once from main.
type GuestReaper struct {
store *Store
retention time.Duration
clock func() time.Time
log *zap.Logger
}
// NewGuestReaper constructs a reaper deleting guests whose account age exceeds
// retention. log may be nil.
func NewGuestReaper(store *Store, retention time.Duration, log *zap.Logger) *GuestReaper {
if log == nil {
log = zap.NewNop()
}
return &GuestReaper{
store: store,
retention: retention,
clock: func() time.Time { return time.Now().UTC() },
log: log,
}
}
// Run reaps abandoned guests on each tick until ctx is cancelled.
func (r *GuestReaper) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
n, err := r.store.ReapAbandonedGuests(ctx, r.clock().Add(-r.retention))
if err != nil {
r.log.Warn("guest reap failed", zap.Error(err))
} else if n > 0 {
r.log.Info("reaped abandoned guests", zap.Int64("count", n))
}
}
}
}
+497
View File
@@ -0,0 +1,497 @@
// Package accountmerge retires a secondary account into a primary one in a single
// transaction: it sums statistics and the hint wallet, ORs the paid flag, repoints
// the secondary's identities, transfers its games/chat/complaints/invitations,
// de-duplicates friends and blocks, and leaves the secondary as an audit tombstone
// (accounts.merged_into). It is the data core of Stage 11 account linking & merge
// (ARCHITECTURE.md §4); session revocation and any session switch are orchestrated
// one layer up (the link service), since the in-memory session cache lives there.
package accountmerge
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// statusActive mirrors game.StatusActive; the active-shared-game guard reads it
// without taking a dependency on the game package.
const statusActive = "active"
// Friendship statuses, highest precedence first, mirroring internal/social.
const (
friendAccepted = "accepted"
friendPending = "pending"
friendDeclined = "declined"
)
// ErrActiveGameConflict is returned when the primary and secondary accounts share
// an active game: merging would seat one player against themselves, so the caller
// must wait for the game to finish.
var ErrActiveGameConflict = errors.New("accountmerge: primary and secondary share an active game")
// ErrSameAccount is returned when primary and secondary are the same account.
var ErrSameAccount = errors.New("accountmerge: primary and secondary are the same account")
// Merger merges accounts over a Postgres handle.
type Merger struct {
db *sql.DB
now func() time.Time
}
// NewMerger constructs a Merger over db.
func NewMerger(db *sql.DB) *Merger {
return &Merger{db: db, now: func() time.Time { return time.Now().UTC() }}
}
// Merge retires secondary into primary atomically. The secondary is kept as a
// tombstone (merged_into=primary) so the no-cascade foreign keys of any shared
// finished game stay valid; its seat in such a game is left untouched. The merge
// is refused with ErrActiveGameConflict when the two share an active game.
func (m *Merger) Merge(ctx context.Context, primary, secondary uuid.UUID) error {
if primary == secondary {
return ErrSameAccount
}
now := m.now()
return withTx(ctx, m.db, func(tx *sql.Tx) error {
if err := guardActiveSharedGame(ctx, tx, primary, secondary); err != nil {
return err
}
if err := mergeStats(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := mergeAccountFields(ctx, tx, primary, secondary, now); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.Identities, table.Identities.AccountID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: identities: %w", err)
}
if err := transferGamePlayers(ctx, tx, primary, secondary); err != nil {
return err
}
if err := reassignColumn(ctx, tx, table.ChatMessages, table.ChatMessages.SenderID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: chat: %w", err)
}
if err := reassignColumn(ctx, tx, table.Complaints, table.Complaints.ComplainantID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: complaints: %w", err)
}
if err := mergeFriendships(ctx, tx, primary, secondary); err != nil {
return err
}
if err := mergeBlocks(ctx, tx, primary, secondary); err != nil {
return err
}
if err := mergeInvitations(ctx, tx, primary, secondary); err != nil {
return err
}
if err := deleteEphemerals(ctx, tx, secondary); err != nil {
return err
}
return tombstone(ctx, tx, primary, secondary, now)
})
}
// guardActiveSharedGame returns ErrActiveGameConflict when primary and secondary
// are both seated in the same active game.
func guardActiveSharedGame(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
pri, err := activeGameIDs(ctx, tx, primary)
if err != nil {
return err
}
if len(pri) == 0 {
return nil
}
sec, err := activeGameIDs(ctx, tx, secondary)
if err != nil {
return err
}
have := make(map[uuid.UUID]struct{}, len(pri))
for _, id := range pri {
have[id] = struct{}{}
}
for _, id := range sec {
if _, ok := have[id]; ok {
return ErrActiveGameConflict
}
}
return nil
}
// activeGameIDs lists the active games accountID is seated in.
func activeGameIDs(ctx context.Context, tx *sql.Tx, accountID uuid.UUID) ([]uuid.UUID, error) {
stmt := postgres.SELECT(table.GamePlayers.GameID).
FROM(table.GamePlayers.INNER_JOIN(table.Games, table.Games.GameID.EQ(table.GamePlayers.GameID))).
WHERE(
table.GamePlayers.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Games.Status.EQ(postgres.String(statusActive))),
)
var rows []model.GamePlayers
if err := stmt.QueryContext(ctx, tx, &rows); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("accountmerge: active games %s: %w", accountID, err)
}
out := make([]uuid.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.GameID)
}
return out, nil
}
// mergeStats folds secondary's lifetime statistics into primary (wins/losses/draws
// summed, max points kept) and deletes the secondary row.
func mergeStats(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.AccountStats
err := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &sec)
if errors.Is(err, qrm.ErrNoRows) {
return nil
}
if err != nil {
return fmt.Errorf("accountmerge: load secondary stats: %w", err)
}
ensure := table.AccountStats.INSERT(table.AccountStats.AccountID).
VALUES(primary).ON_CONFLICT(table.AccountStats.AccountID).DO_NOTHING()
if _, err := ensure.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: ensure primary stats: %w", err)
}
var pri model.AccountStats
if err := postgres.SELECT(table.AccountStats.AllColumns).
FROM(table.AccountStats).
WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary))).
FOR(postgres.UPDATE()).
QueryContext(ctx, tx, &pri); err != nil {
return fmt.Errorf("accountmerge: lock primary stats: %w", err)
}
upd := table.AccountStats.UPDATE(
table.AccountStats.Wins, table.AccountStats.Losses, table.AccountStats.Draws,
table.AccountStats.MaxGamePoints, table.AccountStats.MaxWordPoints, table.AccountStats.UpdatedAt,
).SET(
postgres.Int(int64(pri.Wins+sec.Wins)),
postgres.Int(int64(pri.Losses+sec.Losses)),
postgres.Int(int64(pri.Draws+sec.Draws)),
postgres.Int(int64(max(pri.MaxGamePoints, sec.MaxGamePoints))),
postgres.Int(int64(max(pri.MaxWordPoints, sec.MaxWordPoints))),
postgres.TimestampzT(now),
).WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: update primary stats: %w", err)
}
del := table.AccountStats.DELETE().WHERE(table.AccountStats.AccountID.EQ(postgres.UUID(secondary)))
if _, err := del.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete secondary stats: %w", err)
}
return nil
}
// mergeAccountFields adds secondary's hint wallet to primary and ORs the paid flag;
// all other profile fields stay the primary's.
func mergeAccountFields(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
var sec model.Accounts
if err := postgres.SELECT(table.Accounts.AllColumns).
FROM(table.Accounts).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &sec); err != nil {
return fmt.Errorf("accountmerge: load secondary account: %w", err)
}
upd := table.Accounts.UPDATE(
table.Accounts.HintBalance, table.Accounts.PaidAccount, table.Accounts.UpdatedAt,
).SET(
table.Accounts.HintBalance.ADD(postgres.Int(int64(sec.HintBalance))),
table.Accounts.PaidAccount.OR(postgres.Bool(sec.PaidAccount)),
postgres.TimestampzT(now),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(primary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: update primary account: %w", err)
}
return nil
}
// transferGamePlayers moves secondary's seats to primary, except in a game primary
// already sits in (a shared finished game — active is barred by the guard), where
// the secondary seat is left as the tombstone so the no-cascade FK stays valid.
func transferGamePlayers(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
var prows []model.GamePlayers
if err := postgres.SELECT(table.GamePlayers.GameID).
FROM(table.GamePlayers).
WHERE(table.GamePlayers.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil {
if !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary seats: %w", err)
}
}
cond := table.GamePlayers.AccountID.EQ(postgres.UUID(secondary))
if len(prows) > 0 {
ids := make([]postgres.Expression, len(prows))
for i, r := range prows {
ids[i] = postgres.UUID(r.GameID)
}
cond = cond.AND(table.GamePlayers.GameID.NOT_IN(ids...))
}
upd := table.GamePlayers.UPDATE(table.GamePlayers.AccountID).SET(postgres.UUID(primary)).WHERE(cond)
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: transfer seats: %w", err)
}
return nil
}
// reassignColumn blanket-reassigns a no-collision account column from secondary to
// primary (identities, chat sender, complaint complainant).
func reassignColumn(ctx context.Context, tx *sql.Tx, tbl postgres.Table, col postgres.ColumnString, primary, secondary uuid.UUID) error {
upd := tbl.UPDATE(col).SET(postgres.UUID(primary)).
WHERE(col.EQ(postgres.UUID(secondary)))
_, err := upd.ExecContext(ctx, tx)
return err
}
// friendRank ranks a friendship status for dedupe precedence (higher wins).
func friendRank(status string) int {
switch status {
case friendAccepted:
return 3
case friendPending:
return 2
case friendDeclined:
return 1
default:
return 0
}
}
// mergeFriendships repoints secondary's friendships to primary, dropping the direct
// primary-secondary edge (it would become a self-edge) and de-duplicating a shared
// counterparty by keeping the higher-precedence status (accepted > pending >
// declined). Each account has at most one edge per unordered pair, so the per-other
// decision is unambiguous.
func mergeFriendships(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: drop self-friendship: %w", err)
}
priByOther := map[uuid.UUID]string{}
var prows []model.Friendships
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, &prows); err != nil {
return fmt.Errorf("accountmerge: primary friendships: %w", err)
}
for _, r := range prows {
priByOther[otherOf(r.RequesterID, r.AddresseeID, primary)] = r.Status
}
var srows []model.Friendships
if err := selectEdges(ctx, tx, table.Friendships, table.Friendships.AllColumns, table.Friendships.RequesterID, table.Friendships.AddresseeID, secondary, &srows); err != nil {
return fmt.Errorf("accountmerge: secondary friendships: %w", err)
}
for _, r := range srows {
other := otherOf(r.RequesterID, r.AddresseeID, secondary)
if priStatus, ok := priByOther[other]; ok {
if friendRank(r.Status) <= friendRank(priStatus) {
if err := deleteEdge(ctx, tx, table.Friendships.DELETE(),
table.Friendships.RequesterID, table.Friendships.AddresseeID, r.RequesterID, r.AddresseeID); err != nil {
return fmt.Errorf("accountmerge: drop dominated friendship: %w", err)
}
continue
}
if err := deletePair(ctx, tx, table.Friendships.DELETE(),
table.Friendships.RequesterID, table.Friendships.AddresseeID, primary, other); err != nil {
return fmt.Errorf("accountmerge: drop superseded friendship: %w", err)
}
}
if err := repointEdge(ctx, tx, table.Friendships, table.Friendships.RequesterID, table.Friendships.AddresseeID,
r.RequesterID, r.AddresseeID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: repoint friendship: %w", err)
}
}
return nil
}
// mergeBlocks repoints secondary's blocks to primary, dropping the direct
// primary-secondary block (a self-block) and de-duplicating a counterparty already
// blocked by primary in either direction (a block is undirected for suppression).
func mergeBlocks(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
if err := deletePair(ctx, tx, table.Blocks.DELETE(),
table.Blocks.BlockerID, table.Blocks.BlockedID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: drop self-block: %w", err)
}
priOthers := map[uuid.UUID]struct{}{}
var prows []model.Blocks
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, primary, &prows); err != nil {
return fmt.Errorf("accountmerge: primary blocks: %w", err)
}
for _, r := range prows {
priOthers[otherOf(r.BlockerID, r.BlockedID, primary)] = struct{}{}
}
var srows []model.Blocks
if err := selectEdges(ctx, tx, table.Blocks, table.Blocks.AllColumns, table.Blocks.BlockerID, table.Blocks.BlockedID, secondary, &srows); err != nil {
return fmt.Errorf("accountmerge: secondary blocks: %w", err)
}
for _, r := range srows {
if _, ok := priOthers[otherOf(r.BlockerID, r.BlockedID, secondary)]; ok {
if err := deleteEdge(ctx, tx, table.Blocks.DELETE(),
table.Blocks.BlockerID, table.Blocks.BlockedID, r.BlockerID, r.BlockedID); err != nil {
return fmt.Errorf("accountmerge: drop dup block: %w", err)
}
continue
}
if err := repointEdge(ctx, tx, table.Blocks, table.Blocks.BlockerID, table.Blocks.BlockedID,
r.BlockerID, r.BlockedID, primary, secondary); err != nil {
return fmt.Errorf("accountmerge: repoint block: %w", err)
}
}
return nil
}
// mergeInvitations deletes secondary's pending invitations as inviter (cascading to
// their invitees) and repoints its invitee rows to primary, dropping a row where
// primary is already an invitee of the same invitation.
func mergeInvitations(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID) error {
delInv := table.GameInvitations.DELETE().
WHERE(table.GameInvitations.InviterID.EQ(postgres.UUID(secondary)))
if _, err := delInv.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete secondary invitations: %w", err)
}
priInv := map[uuid.UUID]struct{}{}
var prows []model.GameInvitationInvitees
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(primary))).
QueryContext(ctx, tx, &prows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: primary invitees: %w", err)
}
for _, r := range prows {
priInv[r.InvitationID] = struct{}{}
}
var srows []model.GameInvitationInvitees
if err := postgres.SELECT(table.GameInvitationInvitees.InvitationID).
FROM(table.GameInvitationInvitees).
WHERE(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary))).
QueryContext(ctx, tx, &srows); err != nil && !errors.Is(err, qrm.ErrNoRows) {
return fmt.Errorf("accountmerge: secondary invitees: %w", err)
}
for _, r := range srows {
where := table.GameInvitationInvitees.InvitationID.EQ(postgres.UUID(r.InvitationID)).
AND(table.GameInvitationInvitees.AccountID.EQ(postgres.UUID(secondary)))
if _, dup := priInv[r.InvitationID]; dup {
if _, err := table.GameInvitationInvitees.DELETE().WHERE(where).ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: drop dup invitee: %w", err)
}
continue
}
upd := table.GameInvitationInvitees.UPDATE(table.GameInvitationInvitees.AccountID).
SET(postgres.UUID(primary)).WHERE(where)
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: repoint invitee: %w", err)
}
}
return nil
}
// deleteEphemerals drops the secondary's pending email confirmations and friend
// codes (short-lived, single-use; not worth carrying over).
func deleteEphemerals(ctx context.Context, tx *sql.Tx, secondary uuid.UUID) error {
if _, err := table.EmailConfirmations.DELETE().
WHERE(table.EmailConfirmations.AccountID.EQ(postgres.UUID(secondary))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete confirmations: %w", err)
}
if _, err := table.FriendCodes.DELETE().
WHERE(table.FriendCodes.AccountID.EQ(postgres.UUID(secondary))).
ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: delete friend codes: %w", err)
}
return nil
}
// tombstone marks secondary retired, pointing at primary for audit.
func tombstone(ctx context.Context, tx *sql.Tx, primary, secondary uuid.UUID, now time.Time) error {
upd := table.Accounts.UPDATE(table.Accounts.MergedInto, table.Accounts.MergedAt, table.Accounts.UpdatedAt).
SET(postgres.UUID(primary), postgres.TimestampzT(now), postgres.TimestampzT(now)).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(secondary)))
if _, err := upd.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("accountmerge: tombstone secondary: %w", err)
}
return nil
}
// otherOf returns the endpoint of a two-account edge that is not self.
func otherOf(a, b, self uuid.UUID) uuid.UUID {
if a == self {
return b
}
return a
}
// selectEdges loads the rows of a symmetric two-column edge table touching account.
func selectEdges[T any](ctx context.Context, tx *sql.Tx, tbl postgres.Table, cols postgres.Projection, left, right postgres.ColumnString, account uuid.UUID, dest *[]T) error {
err := postgres.SELECT(cols).
FROM(tbl).
WHERE(left.EQ(postgres.UUID(account)).OR(right.EQ(postgres.UUID(account)))).
QueryContext(ctx, tx, dest)
if errors.Is(err, qrm.ErrNoRows) {
return nil
}
return err
}
// deletePair deletes the directed-or-reverse edge between a and b.
func deletePair(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, a, b uuid.UUID) error {
cond := left.EQ(postgres.UUID(a)).AND(right.EQ(postgres.UUID(b))).
OR(left.EQ(postgres.UUID(b)).AND(right.EQ(postgres.UUID(a))))
_, err := del.WHERE(cond).ExecContext(ctx, tx)
return err
}
// deleteEdge deletes the single edge identified by its (left, right) primary key.
func deleteEdge(ctx context.Context, tx *sql.Tx, del postgres.DeleteStatement, left, right postgres.ColumnString, l, r uuid.UUID) error {
cond := left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(r)))
_, err := del.WHERE(cond).ExecContext(ctx, tx)
return err
}
// repointEdge replaces the secondary endpoint of edge (l, r) with primary, keeping
// the edge's direction.
func repointEdge(ctx context.Context, tx *sql.Tx, tbl postgres.Table, left, right postgres.ColumnString, l, r, primary, secondary uuid.UUID) error {
var col postgres.ColumnString
var where postgres.BoolExpression
if l == secondary {
col, where = left, left.EQ(postgres.UUID(secondary)).AND(right.EQ(postgres.UUID(r)))
} else {
col, where = right, left.EQ(postgres.UUID(l)).AND(right.EQ(postgres.UUID(secondary)))
}
_, err := tbl.UPDATE(col).SET(postgres.UUID(primary)).WHERE(where).ExecContext(ctx, tx)
return err
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("accountmerge: begin tx: %w", err)
}
if err := fn(tx); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("accountmerge: commit tx: %w", err)
}
return nil
}
@@ -0,0 +1,103 @@
/* Admin console stylesheet. Deliberately small and dependency-free: the console
is an internal operator tool served under /_gm, 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;
--warn: #f1c453;
}
* { 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; flex-wrap: wrap; }
.topbar .mainnav a.active { color: var(--ink); border-bottom: 2px solid var(--accent); }
.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(200px, 1fr)); gap: 1rem; margin: 1.2rem 0; }
.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 .bignum { font-size: 1.8rem; margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
.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); }
.kv { list-style: none; margin: 0; padding: 0; }
.kv li { padding: 0.15rem 0; color: var(--ink-dim); }
.kv li b { color: var(--ink); font-weight: 600; }
.note { color: var(--ink-dim); font-style: italic; margin: 0.2rem 0; }
.ok { color: var(--ok); }
.bad { color: var(--danger); }
.warn { color: var(--warn); }
.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); }
.list td.num { text-align: right; font-variant-numeric: tabular-nums; }
.pager { display: flex; gap: 1rem; align-items: center; color: var(--ink-dim); }
.subnav { color: var(--ink-dim); margin: -0.2rem 0 1rem; font-size: 0.9rem; }
.subnav a.active { color: var(--ink); }
.form { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end; margin-top: 0.4rem; }
.form.col { flex-direction: column; align-items: stretch; max-width: 540px; }
.form label { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; color: var(--ink-dim); }
.form input, .form select, .form textarea {
background: var(--bg);
color: var(--ink);
border: 1px solid var(--line);
border-radius: 6px;
padding: 0.35rem 0.5rem;
font: inherit;
}
.form textarea { min-height: 4rem; resize: vertical; }
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; }
.pill { padding: 0.05rem 0.4rem; border: 1px solid var(--line); border-radius: 999px; font-size: 0.8rem; }
+9
View File
@@ -0,0 +1,9 @@
// Package adminconsole renders the backend's server-side admin console: a small,
// dependency-free set of Go html/template pages plus one embedded stylesheet,
// served under /_gm. It owns the rendering and the page view models only; the gin
// handlers (internal/server) fetch the domain data, populate the view models and
// gate the surface — the gateway puts HTTP Basic-Auth in front of /_gm and a
// same-origin check guards the POST actions (docs/ARCHITECTURE.md §12). It mirrors
// the shape of galaxy-game's adminconsole package, minus the per-operator CSRF
// token and operator name (this console tracks no operator identity).
package adminconsole
+101
View File
@@ -0,0 +1,101 @@
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 (the page chrome and the
// "layout" entry template) with that page's "content" block, so rendering a page
// is a single ExecuteTemplate call against "layout".
type Renderer struct {
pages map[string]*template.Template
}
// PageData is the view model passed to every admin console page. Title is the
// document title; ActiveNav marks the highlighted navigation entry; Data carries
// the page-specific payload (one of the *View types in views.go).
type PageData struct {
Title string
ActiveNav string
Data any
}
// NewRenderer parses the embedded layout and every content page under
// templates/pages. 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.
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
// renders into an intermediate buffer first, so a mid-render failure never emits
// a partial document. It returns an error for an unknown page or a failed render.
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,69 @@
package adminconsole
import (
"bytes"
"io/fs"
"strings"
"testing"
)
// TestRendererRendersEveryPage parses the embedded templates and renders each
// page with a representative view, asserting the page executes, carries the
// shared layout chrome and shows a distinctive value.
func TestRendererRendersEveryPage(t *testing.T) {
r, err := NewRenderer()
if err != nil {
t.Fatalf("new renderer: %v", err)
}
cases := []struct {
page string
data any
want string
}{
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya"}}, Pager: NewPager(1, 50, 1)}, "Kaya"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"games", GamesView{Items: []GameRow{{ID: "g1", Variant: "english", Status: "active"}}, Status: "active", Pager: NewPager(1, 50, 1)}, "g1"},
{"game_detail", GameDetailView{ID: "g1", Variant: "english", Seats: []SeatRow{{Seat: 0, DisplayName: "Kaya"}}}, "Seats"},
{"complaints", ComplaintsView{Items: []ComplaintRow{{ID: "c1", Word: "qi", Status: "open"}}, Status: "open", Pager: NewPager(1, 50, 1)}, "qi"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "english"}, "Resolve"},
{"dictionary", DictionaryView{Variants: []VariantVersions{{Variant: "english", Latest: "v1", Versions: []string{"v1"}}}, Changes: []DictChangeRow{{Variant: "english", Word: "qi", Action: "add"}}}, "Hot-reload"},
{"broadcast", BroadcastView{ConnectorEnabled: true}, "Post to the game channel"},
{"message", MessageView{Heading: "Done", Body: "ok", Back: "/_gm/"}, "Done"},
}
for _, tc := range cases {
t.Run(tc.page, func(t *testing.T) {
var buf bytes.Buffer
if err := r.Render(&buf, tc.page, PageData{Title: tc.page, Data: tc.data}); err != nil {
t.Fatalf("render %s: %v", tc.page, err)
}
out := buf.String()
if !strings.Contains(out, tc.want) {
t.Errorf("render %s: missing %q in output", tc.page, tc.want)
}
if !strings.Contains(out, "Scrabble · admin") {
t.Errorf("render %s: missing layout chrome", tc.page)
}
})
}
}
// TestRendererUnknownPage reports an error for a page that does not exist.
func TestRendererUnknownPage(t *testing.T) {
r := MustNewRenderer()
if err := r.Render(&bytes.Buffer{}, "nope", PageData{}); err == nil {
t.Fatal("expected an error rendering an unknown page")
}
}
// TestAssets confirms the stylesheet is embedded and reachable under the assets
// root.
func TestAssets(t *testing.T) {
fsys, err := Assets()
if err != nil {
t.Fatalf("assets: %v", err)
}
if _, err := fs.Stat(fsys, "console.css"); err != nil {
t.Errorf("console.css not embedded: %v", err)
}
}
@@ -0,0 +1,28 @@
{{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}} · Scrabble admin</title>
<link rel="stylesheet" href="/_gm/assets/console.css">
</head>
<body>
<header class="topbar">
<span class="brand">Scrabble · admin</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/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
</nav>
</header>
<main class="content">
{{template "content" .}}
</main>
</body>
</html>
{{- end}}
@@ -0,0 +1,15 @@
{{define "content" -}}
<h1>Broadcast</h1>
{{with .Data}}
<section class="panel"><h2>Post to the game channel</h2>
{{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/broadcast">
<label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Post to channel</button></div>
</form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section>
<p class="note">To message a single user, open their <a href="/_gm/users">user page</a>.</p>
{{end}}
{{- end}}
@@ -0,0 +1,32 @@
{{define "content" -}}
{{with .Data}}
<h1>Complaint: {{.Word}}</h1>
<nav class="subnav"><a href="/_gm/complaints">&laquo; complaints</a></nav>
<section class="panel"><h2>Details</h2>
<ul class="kv">
<li><b>Word</b> <code>{{.Word}}</code></li>
<li><b>Variant</b> {{.Variant}}</li>
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Lookup at filing</b> {{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</li>
<li><b>Filer note</b> {{if .Note}}{{.Note}}{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Game</b> <a href="/_gm/games/{{.GameID}}">{{.GameID}}</a></li>
<li><b>Filed</b> {{.CreatedAt}}</li>
<li><b>Status</b> {{.Status}}</li>
{{if .Resolved}}<li><b>Disposition</b> {{.Disposition}}</li><li><b>Resolution note</b> {{.ResolutionNote}}</li><li><b>Resolved</b> {{.ResolvedAt}}</li>{{end}}
</ul>
</section>
<section class="panel"><h2>{{if .Resolved}}Re-resolve{{else}}Resolve{{end}}</h2>
<form class="form col" method="post" action="/_gm/complaints/{{.ID}}/resolve">
<label>Disposition
<select name="disposition">
<option value="reject">reject — dictionary is correct</option>
<option value="accept_add">accept — add word to the dictionary</option>
<option value="accept_remove">accept — remove word from the dictionary</option>
</select>
</label>
<label>Note <textarea name="note"></textarea></label>
<div><button type="submit">Resolve</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,30 @@
{{define "content" -}}
<h1>Complaints</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/complaints?status=open"{{if eq .Status "open"}} class="active"{{end}}>open</a> ·
<a href="/_gm/complaints?status=resolved"{{if eq .Status "resolved"}} class="active"{{end}}>resolved</a> ·
<a href="/_gm/complaints"{{if eq .Status ""}} class="active"{{end}}>all</a>
</nav>
<table class="list">
<thead><tr><th>Word</th><th>Variant</th><th>Was valid</th><th>Status</th><th>Disposition</th><th>Filed</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/complaints/{{.ID}}">{{.Word}}</a></td>
<td>{{.Variant}}</td>
<td>{{if .WasValid}}<span class="ok">valid</span>{{else}}<span class="bad">invalid</span>{{end}}</td>
<td>{{.Status}}</td>
<td>{{.Disposition}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}<tr><td colspan="6"><span class="note">no complaints</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/complaints?status={{.Status}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -0,0 +1,24 @@
{{define "content" -}}
<h1>Dashboard</h1>
<p class="lede">Operator console for users, games, complaints and dictionaries.</p>
{{with .Data}}
<div class="cards">
<a class="card" href="/_gm/users"><h2>Users</h2><p class="bignum">{{.Accounts}}</p></a>
<a class="card" href="/_gm/games"><h2>Games</h2><p class="bignum">{{.Games}}</p></a>
<a class="card" href="/_gm/games?status=active"><h2>Active games</h2><p class="bignum">{{.ActiveGames}}</p></a>
<a class="card" href="/_gm/complaints?status=open"><h2>Open complaints</h2><p class="bignum">{{.OpenComplaints}}</p></a>
<a class="card" href="/_gm/dictionary"><h2>Pending dict changes</h2><p class="bignum">{{.PendingChanges}}</p></a>
</div>
<section class="panel">
<h2>Dictionaries</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Latest</th><th>Resident versions</th></tr></thead>
<tbody>
{{range .Variants}}
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,43 @@
{{define "content" -}}
<h1>Dictionary</h1>
{{with .Data}}
<section class="panel"><h2>Resident versions</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Latest</th><th>Resident</th></tr></thead>
<tbody>
{{range .Variants}}
<tr><td>{{.Variant}}</td><td>{{.Latest}}</td><td>{{range .Versions}}<span class="pill">{{.}}</span> {{end}}</td></tr>
{{end}}
</tbody>
</table>
</section>
<section class="panel"><h2>Hot-reload a version</h2>
<p class="note">Drop the rebuilt DAWG set into BACKEND_DICT_DIR/&lt;version&gt;/ first, then load it here.</p>
<form class="form" method="post" action="/_gm/dictionary/reload">
<label>Version <input type="text" name="version" placeholder="v2" required></label>
<div><button type="submit">Reload</button></div>
</form>
</section>
<section class="panel"><h2>Pending dictionary changes</h2>
<table class="list">
<thead><tr><th>Variant</th><th>Action</th><th>Word</th><th>Resolved</th></tr></thead>
<tbody>
{{range .Changes}}
<tr><td>{{.Variant}}</td><td>{{.Action}}</td><td><code>{{.Word}}</code></td><td>{{.ResolvedAt}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">no pending changes</span></td></tr>{{end}}
</tbody>
</table>
<form class="form" method="post" action="/_gm/dictionary/changes/apply">
<label>Mark applied for variant
<select name="variant">
<option value="english">english</option>
<option value="russian_scrabble">russian_scrabble</option>
<option value="erudit">erudit</option>
</select>
</label>
<label>In version <input type="text" name="version" placeholder="v2" required></label>
<div><button type="submit">Mark applied</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,29 @@
{{define "content" -}}
{{with .Data}}
<h1>Game {{.ID}}</h1>
<nav class="subnav"><a href="/_gm/games">&laquo; games</a></nav>
<section class="panel"><h2>Summary</h2>
<ul class="kv">
<li><b>Variant</b> {{.Variant}}</li>
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li>
<li><b>Created</b> {{.CreatedAt}}</li>
<li><b>Updated</b> {{.UpdatedAt}}</li>
{{if .FinishedAt}}<li><b>Finished</b> {{.FinishedAt}}</li>{{end}}
</ul>
</section>
<section class="panel"><h2>Seats</h2>
<table class="list">
<thead><tr><th class="num">Seat</th><th>Player</th><th class="num">Score</th><th class="num">Hints</th><th>Winner</th></tr></thead>
<tbody>
{{range .Seats}}
<tr><td class="num">{{.Seat}}</td><td><a href="/_gm/users/{{.AccountID}}">{{.DisplayName}}</a></td><td class="num">{{.Score}}</td><td class="num">{{.HintsUsed}}</td><td>{{if .Winner}}<span class="ok">winner</span>{{end}}</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,23 @@
{{define "content" -}}
<h1>Games</h1>
{{with .Data}}
<nav class="subnav">
<a href="/_gm/games"{{if eq .Status ""}} class="active"{{end}}>all</a> ·
<a href="/_gm/games?status=active"{{if eq .Status "active"}} class="active"{{end}}>active</a> ·
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/games?status={{.Status}}&amp;page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&amp;page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
@@ -0,0 +1,7 @@
{{define "content" -}}
{{with .Data}}
<h1>{{.Heading}}</h1>
<p>{{.Body}}</p>
<p><a href="{{.Back}}">&laquo; back</a></p>
{{end}}
{{- end}}
@@ -0,0 +1,63 @@
{{define "content" -}}
{{with .Data}}
<h1>{{.DisplayName}}</h1>
<nav class="subnav"><a href="/_gm/users">&laquo; users</a></nav>
<div class="cards">
<section class="panel"><h2>Account</h2>
<ul class="kv">
<li><b>ID</b> {{.ID}}</li>
<li><b>Language</b> {{.Language}}</li>
<li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li>
</ul>
</section>
<section class="panel"><h2>Statistics</h2>
{{if .HasStats}}
<ul class="kv">
<li><b>Wins</b> {{.Stats.Wins}}</li>
<li><b>Losses</b> {{.Stats.Losses}}</li>
<li><b>Draws</b> {{.Stats.Draws}}</li>
<li><b>Best game</b> {{.Stats.MaxGamePoints}}</li>
<li><b>Best move</b> {{.Stats.MaxWordPoints}}</li>
</ul>
{{else}}<p class="note">no statistics</p>{{end}}
</section>
</div>
<section class="panel"><h2>Identities</h2>
<table class="list">
<thead><tr><th>Kind</th><th>External ID</th><th>Confirmed</th><th>Created</th></tr></thead>
<tbody>
{{range .Identities}}
<tr><td>{{.Kind}}</td><td><code>{{.ExternalID}}</code></td><td>{{if .Confirmed}}<span class="ok">yes</span>{{else}}<span class="warn">no</span>{{end}}</td><td>{{.CreatedAt}}</td></tr>
{{else}}<tr><td colspan="4"><span class="note">no identities (guest)</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{if .TelegramID}}
<section class="panel"><h2>Send Telegram message</h2>
{{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
<label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Send to user</button></div>
</form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section>
{{end}}
<section class="panel"><h2>Games</h2>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Games}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,26 @@
{{define "content" -}}
<h1>Users</h1>
{{with .Data}}
<table class="list">
<thead><tr><th>Account</th><th>Display name</th><th>Kind</th><th>Lang</th><th>Created</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td><a href="/_gm/users/{{.ID}}">{{.ID}}</a></td>
<td>{{.DisplayName}}{{if .Guest}} <span class="pill">guest</span>{{end}}</td>
<td>{{.Kind}}</td>
<td>{{.Language}}</td>
<td>{{.CreatedAt}}</td>
</tr>
{{else}}
<tr><td colspan="5"><span class="note">no users</span></td></tr>
{{end}}
</tbody>
</table>
<nav class="pager">
{{if .Pager.HasPrev}}<a href="/_gm/users?page={{.Pager.PrevPage}}">&laquo; prev</a>{{end}}
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next &raquo;</a>{{end}}
</nav>
{{end}}
{{- end}}
+204
View File
@@ -0,0 +1,204 @@
package adminconsole
// The *View types are the display models the gin handlers fill and the templates
// render. Time values are pre-formatted to strings by the handlers so the
// templates stay logic-free.
// Pager is the shared list pagination state.
type Pager struct {
Page int
PageSize int
Total int
HasPrev bool
HasNext bool
PrevPage int
NextPage int
}
// NewPager builds the pagination state for a 1-based page of pageSize over total
// items.
func NewPager(page, pageSize, total int) Pager {
if page < 1 {
page = 1
}
p := Pager{Page: page, PageSize: pageSize, Total: total, PrevPage: page - 1, NextPage: page + 1}
p.HasPrev = page > 1
p.HasNext = page*pageSize < total
return p
}
// VariantVersions lists the dictionary versions resident for one variant.
type VariantVersions struct {
Variant string
Latest string
Versions []string
}
// DashboardView is the landing-page summary.
type DashboardView struct {
Accounts int
Games int
ActiveGames int
OpenComplaints int
PendingChanges int
Variants []VariantVersions
}
// UsersView is the paginated account list.
type UsersView struct {
Items []UserRow
Pager Pager
}
// UserRow is one account row in the list.
type UserRow struct {
ID string
DisplayName string
Kind string
Language string
Guest bool
CreatedAt string
}
// UserDetailView is one account with its stats, identities and recent games.
type UserDetailView struct {
ID string
DisplayName string
Language string
TimeZone string
Guest bool
NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a
// merge (Stage 11), or empty for a live account.
MergedInto string
HintBalance int
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
Games []GameRow
TelegramID string
ConnectorEnabled bool
}
// StatsRow is an account's lifetime statistics.
type StatsRow struct {
Wins int
Losses int
Draws int
MaxGamePoints int
MaxWordPoints int
}
// IdentityRow is one platform/email identity of an account.
type IdentityRow struct {
Kind string
ExternalID string
Confirmed bool
CreatedAt string
}
// GameRow is one game row in a list.
type GameRow struct {
ID string
Variant string
Status string
Players int
UpdatedAt string
}
// GamesView is the paginated games list, optionally filtered by status.
type GamesView struct {
Items []GameRow
Status string
Pager Pager
}
// GameDetailView is one game with its seats.
type GameDetailView struct {
ID string
Variant string
DictVersion string
Status string
Players int
ToMove int
EndReason string
MoveCount int
CreatedAt string
UpdatedAt string
FinishedAt string
Seats []SeatRow
}
// SeatRow is one seat of a game.
type SeatRow struct {
Seat int
DisplayName string
AccountID string
Score int
HintsUsed int
Winner bool
}
// ComplaintsView is the paginated complaint review queue.
type ComplaintsView struct {
Items []ComplaintRow
Status string
Pager Pager
}
// ComplaintRow is one complaint row in the queue.
type ComplaintRow struct {
ID string
Word string
Variant string
WasValid bool
Status string
Disposition string
CreatedAt string
}
// ComplaintDetailView is one complaint with its resolution state and form.
type ComplaintDetailView struct {
ID string
Word string
Variant string
DictVersion string
WasValid bool
Note string
Status string
Disposition string
ResolutionNote string
CreatedAt string
ResolvedAt string
GameID string
Resolved bool
}
// DictionaryView lists the resident versions per variant and the pending
// wordlist changes from accepted complaints.
type DictionaryView struct {
Variants []VariantVersions
Changes []DictChangeRow
}
// DictChangeRow is one pending wordlist edit.
type DictChangeRow struct {
Variant string
Word string
Action string
ResolvedAt string
}
// BroadcastView is the operator-broadcast form page.
type BroadcastView struct {
ConnectorEnabled bool
}
// MessageView is the result page shown after a POST action.
type MessageView struct {
Heading string
Body string
Back string
}
+29
View File
@@ -38,6 +38,15 @@ type Config struct {
// SMTP configures the email relay used for confirm-codes. An empty Host
// selects the development log mailer (the code is logged, not sent).
SMTP account.SMTPConfig
// ConnectorAddr is the gRPC address of the Telegram platform connector
// side-service, used by the admin console to send operator broadcasts. Empty
// disables broadcasts (the admin broadcast actions report "not configured").
ConnectorAddr string
// GuestReapInterval is the cadence of the abandoned-guest reaper sweep.
GuestReapInterval time.Duration
// GuestRetention is the account age past which an unused guest (no game seat)
// is eligible for deletion by the reaper.
GuestRetention time.Duration
}
// Defaults applied when the corresponding environment variable is unset.
@@ -45,6 +54,8 @@ const (
defaultHTTPAddr = ":8080"
defaultGRPCAddr = ":9090"
defaultLogLevel = "info"
defaultGuestReapInterval = time.Hour
defaultGuestRetention = 30 * 24 * time.Hour
)
// Load reads the configuration from the environment, applies defaults for unset
@@ -94,6 +105,15 @@ func Load() (Config, error) {
return Config{}, err
}
guestReapInterval, err := envDuration("BACKEND_GUEST_REAP_INTERVAL", defaultGuestReapInterval)
if err != nil {
return Config{}, err
}
guestRetention, err := envDuration("BACKEND_GUEST_RETENTION", defaultGuestRetention)
if err != nil {
return Config{}, err
}
smtp := account.SMTPConfig{
Host: os.Getenv("BACKEND_SMTP_HOST"),
Port: envOr("BACKEND_SMTP_PORT", "587"),
@@ -112,6 +132,9 @@ func Load() (Config, error) {
Lobby: lb,
Robot: rb,
SMTP: smtp,
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
GuestReapInterval: guestReapInterval,
GuestRetention: guestRetention,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -147,6 +170,12 @@ func (c Config) validate() error {
if err := c.Robot.Validate(); err != nil {
return fmt.Errorf("config: %w", err)
}
if c.GuestReapInterval <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_REAP_INTERVAL must be positive")
}
if c.GuestRetention <= 0 {
return fmt.Errorf("config: BACKEND_GUEST_RETENTION must be positive")
}
return nil
}
+45 -3
View File
@@ -151,12 +151,54 @@ func TestLoadRejectsMalformedDuration(t *testing.T) {
}
}
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the MVP
// set is rejected.
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the
// supported set is rejected.
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "prometheus")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
}
}
// TestLoadAcceptsOTLPExporter verifies that the otlp exporter is now accepted
// (the collector is stood up with the deploy; the default stays none).
func TestLoadAcceptsOTLPExporter(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_DICT_DIR", "/dict")
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
t.Setenv("BACKEND_OTEL_METRICS_EXPORTER", "otlp")
if _, err := Load(); err != nil {
t.Fatalf("Load with otlp exporters: %v", err)
}
}
// TestLoadGuestReaperDefaultsAndOverride covers the guest-reaper knobs: defaults
// when unset, an override, and rejection of a non-positive value.
func TestLoadGuestReaperDefaultsAndOverride(t *testing.T) {
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
t.Setenv("BACKEND_DICT_DIR", "/dict")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.GuestReapInterval != defaultGuestReapInterval {
t.Errorf("GuestReapInterval = %s, want %s", c.GuestReapInterval, defaultGuestReapInterval)
}
if c.GuestRetention != defaultGuestRetention {
t.Errorf("GuestRetention = %s, want %s", c.GuestRetention, defaultGuestRetention)
}
t.Setenv("BACKEND_GUEST_RETENTION", "168h")
if c, err = Load(); err != nil {
t.Fatalf("Load (override): %v", err)
} else if c.GuestRetention != 168*time.Hour {
t.Errorf("GuestRetention = %s, want 168h", c.GuestRetention)
}
t.Setenv("BACKEND_GUEST_REAP_INTERVAL", "0s")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a non-positive reap interval, got nil")
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package connector is the backend's gRPC client for the Telegram platform
// connector side-service. The admin console uses it to send operator broadcasts:
// a direct message to one user, or a post to a game channel. Each broadcast
// selects the delivering bot by language (an operator choice, since the connector
// hosts one bot per service language). The connector lives on the trusted internal
// network, so the connection uses insecure (plaintext) transport credentials
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
// the two broadcast methods the admin surface needs.
package connector
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// Client wraps the connector's Telegram gRPC service.
type Client struct {
conn *grpc.ClientConn
c telegramv1.TelegramClient
}
// New dials the connector gRPC endpoint at addr.
func New(addr string) (*Client, error) {
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, fmt.Errorf("connector: dial %s: %w", addr, err)
}
return &Client{conn: conn, c: telegramv1.NewTelegramClient(conn)}, nil
}
// Close releases the gRPC connection.
func (c *Client) Close() error { return c.conn.Close() }
// SendToUser sends an operator text message to one user, addressed by their
// platform external_id, through the bot for the given language. delivered reports
// whether the connector actually sent it (false when the user has not started that
// bot).
func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) {
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
// SendToGameChannel posts an operator text message to the game channel of the bot
// for the given language. delivered reports whether the connector sent it (false
// when that bot has no channel configured).
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
+150
View File
@@ -0,0 +1,150 @@
package engine
import (
"fmt"
"strings"
)
// AlphabetEntry is one letter of a variant's alphabet: its alphabet-index byte, the
// concrete character and its tile point value. It is the dictionary-independent display
// table the edge sends to the client (Stage 13), produced from the variant's solver
// ruleset (its alphabet and value table) and so pinned by the solver version, not by any
// dictionary.
type AlphabetEntry struct {
// Index is the alphabet-index byte the wire uses for this letter (0..Size-1).
Index byte
// Letter is the concrete character, in the case the solver ruleset emits (lower).
Letter string
// Value is the tile's point score.
Value int
}
// BlankIndex is the wire sentinel for a blank tile inside an alphabet-index sequence (a
// rack or an exchange list). It is out of range of every offered variant's alphabet (the
// largest has 33 letters), so it never collides with a real letter index. A placed blank
// instead travels as an ordinary tile carrying its designated letter's index alongside a
// separate blank flag. The constant is untyped so it serves both byte (FlatBuffers ubyte)
// and int (the gateway/backend JSON edge) call sites.
const BlankIndex = 0xFF
// variantCodec is the cached per-variant alphabet data backing the wire helpers: the
// ordered display table and a case-insensitive letter→index lookup. Both are derived once
// from the solver ruleset (see variantCodecs).
type variantCodec struct {
table []AlphabetEntry
letterToIndex map[string]byte
}
// variantCodecs holds one codec per offered variant, built once at package load from each
// ruleset's alphabet and value table. The rulesets are needed only here (not per request),
// so the hot path never rebuilds them.
var variantCodecs = buildVariantCodecs()
func buildVariantCodecs() map[Variant]*variantCodec {
m := make(map[Variant]*variantCodec, len(Variants()))
for _, v := range Variants() {
rs, ok := v.ruleset()
if !ok {
continue
}
size := rs.Alphabet.Size()
table := make([]AlphabetEntry, size)
lut := make(map[string]byte, size)
for i := range size {
ch, err := rs.Alphabet.Character(byte(i))
if err != nil {
// An offered variant's alphabet never yields a bad index; skip defensively.
continue
}
table[i] = AlphabetEntry{Index: byte(i), Letter: ch, Value: rs.Values[i]}
lut[strings.ToLower(ch)] = byte(i)
}
m[v] = &variantCodec{table: table, letterToIndex: lut}
}
return m
}
// AlphabetTable returns a copy of variant's full alphabet as an ordered (index, letter,
// value) table, or ErrUnknownVariant. Entry i has Index i, so the slice doubles as an
// index→(letter, value) lookup. It needs no dictionary — the data comes from the solver
// ruleset alone — so it is safe to build for any offered variant and is the same table the
// client caches for display while live play exchanges bare indices.
func AlphabetTable(v Variant) ([]AlphabetEntry, error) {
c, ok := variantCodecs[v]
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
out := make([]AlphabetEntry, len(c.table))
copy(out, c.table)
return out, nil
}
// LetterForIndex maps one alphabet index to its concrete letter for variant. It is the
// wire-decode primitive for a placed tile (a blank carries its designated letter's index).
// An out-of-range index is an illegal play.
func LetterForIndex(v Variant, idx int) (string, error) {
c, ok := variantCodecs[v]
if !ok {
return "", fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
if idx < 0 || idx >= len(c.table) {
return "", fmt.Errorf("%w: alphabet index %d for %s", ErrIllegalPlay, idx, v)
}
return c.table[idx].Letter, nil
}
// EncodeRack maps a decoded rack (the Game.Hand form: concrete letters with "?" for an
// undesignated blank) to wire alphabet indices, using BlankIndex for each blank. It backs
// the per-player state view, whose rack the client renders via the cached table.
func EncodeRack(v Variant, letters []string) ([]int, error) {
c, ok := variantCodecs[v]
if !ok {
return nil, fmt.Errorf("%w: %d", ErrUnknownVariant, v)
}
out := make([]int, len(letters))
for i, l := range letters {
if l == blankLetter {
out[i] = BlankIndex
continue
}
idx, ok := c.letterToIndex[strings.ToLower(l)]
if !ok {
return nil, fmt.Errorf("%w: rack letter %q for %s", ErrTilesNotOnRack, l, v)
}
out[i] = int(idx)
}
return out, nil
}
// DecodeTiles maps a wire rack/exchange index list back to the decoded letter form ("?"
// for a blank, BlankIndex), for handing to the existing letter-based exchange path.
func DecodeTiles(v Variant, idx []int) ([]string, error) {
out := make([]string, len(idx))
for i, x := range idx {
if x == BlankIndex {
out[i] = blankLetter
continue
}
l, err := LetterForIndex(v, x)
if err != nil {
return nil, fmt.Errorf("%w (exchange)", err)
}
out[i] = l
}
return out, nil
}
// DecodeWord maps a sequence of alphabet indices to a concrete word (word-check carries no
// blanks). The client constrains input to the variant's alphabet, so every index is a real
// letter.
func DecodeWord(v Variant, idx []int) (string, error) {
var sb strings.Builder
for _, x := range idx {
l, err := LetterForIndex(v, x)
if err != nil {
return "", fmt.Errorf("%w (word check)", err)
}
sb.WriteString(l)
}
return sb.String(), nil
}
+110
View File
@@ -0,0 +1,110 @@
package engine
import (
"errors"
"slices"
"testing"
)
// TestAlphabetTableEnglish pins the English table against the solver ruleset: 26 letters,
// contiguous indices, the concrete lower-case characters the solver emits and the standard
// tile values. This is the real parity check the UI no longer carries (Stage 13).
func TestAlphabetTableEnglish(t *testing.T) {
tab, err := AlphabetTable(VariantEnglish)
if err != nil {
t.Fatalf("AlphabetTable(english): %v", err)
}
if len(tab) != 26 {
t.Fatalf("size = %d, want 26", len(tab))
}
for i, e := range tab {
if int(e.Index) != i {
t.Errorf("entry %d has Index %d, want %d (index must equal position)", i, e.Index, i)
}
}
// a=index0/value1, q=index16/value10, z=index25/value10.
if tab[0].Letter != "a" || tab[0].Value != 1 {
t.Errorf("entry 0 = %q/%d, want a/1", tab[0].Letter, tab[0].Value)
}
if tab[16].Letter != "q" || tab[16].Value != 10 {
t.Errorf("entry 16 = %q/%d, want q/10", tab[16].Letter, tab[16].Value)
}
if tab[25].Letter != "z" || tab[25].Value != 10 {
t.Errorf("entry 25 = %q/%d, want z/10", tab[25].Letter, tab[25].Value)
}
}
// TestAlphabetTableRussianVariants pins both Russian variants: they share the 33-letter
// alphabet but differ in tile values — most visibly ё (index 6), worth 3 in Russian
// Scrabble and 0 in Эрудит.
func TestAlphabetTableRussianVariants(t *testing.T) {
ru, err := AlphabetTable(VariantRussianScrabble)
if err != nil {
t.Fatalf("AlphabetTable(russian_scrabble): %v", err)
}
er, err := AlphabetTable(VariantErudit)
if err != nil {
t.Fatalf("AlphabetTable(erudit): %v", err)
}
if len(ru) != 33 || len(er) != 33 {
t.Fatalf("sizes = %d/%d, want 33/33", len(ru), len(er))
}
if ru[0].Letter != "а" || ru[0].Value != 1 {
t.Errorf("russian entry 0 = %q/%d, want а/1", ru[0].Letter, ru[0].Value)
}
if ru[6].Letter != "ё" || ru[6].Value != 3 {
t.Errorf("russian ё (entry 6) = %q/%d, want ё/3", ru[6].Letter, ru[6].Value)
}
if er[6].Letter != "ё" || er[6].Value != 0 {
t.Errorf("erudit ё (entry 6) = %q/%d, want ё/0", er[6].Letter, er[6].Value)
}
if ru[32].Letter != "я" || er[32].Letter != "я" {
t.Errorf("last letter = %q/%q, want я/я", ru[32].Letter, er[32].Letter)
}
}
// TestAlphabetTableUnknownVariant rejects a variant outside the catalogue.
func TestAlphabetTableUnknownVariant(t *testing.T) {
if _, err := AlphabetTable(Variant(99)); !errors.Is(err, ErrUnknownVariant) {
t.Fatalf("got %v, want ErrUnknownVariant", err)
}
}
// TestRackCodecRoundTrip pins the rack/exchange index codec the edge uses: EncodeRack maps
// concrete letters (with "?" for a blank) to indices (BlankIndex for the blank) and
// DecodeTiles inverts it. EncodeRack is case-insensitive so it accepts the lower-case
// Hand form and an upper-case letter alike.
func TestRackCodecRoundTrip(t *testing.T) {
letters := []string{"c", "a", "t", "?"}
idx, err := EncodeRack(VariantEnglish, letters)
if err != nil {
t.Fatalf("EncodeRack: %v", err)
}
if want := []int{2, 0, 19, BlankIndex}; !slices.Equal(idx, want) {
t.Fatalf("EncodeRack = %v, want %v", idx, want)
}
back, err := DecodeTiles(VariantEnglish, idx)
if err != nil {
t.Fatalf("DecodeTiles: %v", err)
}
if !slices.Equal(back, letters) {
t.Fatalf("DecodeTiles = %v, want %v", back, letters)
}
if up, err := EncodeRack(VariantEnglish, []string{"C"}); err != nil || !slices.Equal(up, []int{2}) {
t.Errorf("EncodeRack upper-case = %v,%v; want [2],nil", up, err)
}
}
// TestDecodeWordAndBounds covers the word-check decode and the out-of-range guard.
func TestDecodeWordAndBounds(t *testing.T) {
w, err := DecodeWord(VariantEnglish, []int{2, 0, 19})
if err != nil || w != "cat" {
t.Fatalf("DecodeWord = %q,%v; want cat,nil", w, err)
}
if _, err := LetterForIndex(VariantEnglish, 26); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("out-of-range index: got %v, want ErrIllegalPlay", err)
}
if _, err := DecodeWord(VariantEnglish, []int{BlankIndex}); !errors.Is(err, ErrIllegalPlay) {
t.Errorf("blank in word: got %v, want ErrIllegalPlay", err)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ package engine
import (
"math/rand"
"scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// blankTile marks a blank tile in a hand or in the bag, matching the
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"slices"
"testing"
"scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// allTiles returns the full multiset of tiles a bag is filled from, in ruleset
+3 -3
View File
@@ -3,9 +3,9 @@ package engine
import (
"fmt"
"scrabble-solver/board"
"scrabble-solver/rules"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// ActionKind classifies a turn in the move log.
+1 -1
View File
@@ -3,7 +3,7 @@ package engine
import (
"testing"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// blankCellFlag is the bit board cells set for a blank tile (board.go encoding).
+1 -1
View File
@@ -3,7 +3,7 @@ package engine
import (
"fmt"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"errors"
"fmt"
"scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
)
// Variant identifies a Scrabble variant the backend offers. Each maps to a
+4 -4
View File
@@ -3,10 +3,10 @@ package engine
import (
"fmt"
"scrabble-solver/board"
"scrabble-solver/rack"
"scrabble-solver/rules"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// scorelessLimit is the number of consecutive scoreless turns (passes and
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"errors"
"testing"
"scrabble-solver/board"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// newEnglishGame starts a two-player English game with the given seed.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"runtime"
"testing"
"scrabble-solver/rules"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// testVersion labels the single dictionary version the tests register.
+55 -1
View File
@@ -2,13 +2,14 @@ package engine
import (
"fmt"
"os"
"path/filepath"
"sort"
"sync"
dawg "github.com/iliadenisov/dafsa"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// dictFiles maps each variant to its committed DAWG filename, as built by
@@ -63,6 +64,36 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
return r, nil
}
// OpenWithVersions builds a Registry by loading the boot version from the flat
// dir (every variant, as Open) and then every additional version held in an
// immediate subdirectory of dir: a subdirectory named V contributes, under
// version V, the variants whose committed DAWG it carries. This is the
// restart-side of the admin dictionary reload — a version reloaded into dir/<V>/
// at runtime is resident again after a restart. A subdirectory named like the
// boot version is skipped (the flat dir already is the boot version). A partially
// loaded registry is closed before any error is returned.
func OpenWithVersions(dir, bootVersion string) (*Registry, error) {
r, err := Open(dir, bootVersion)
if err != nil {
return nil, err
}
entries, err := os.ReadDir(dir)
if err != nil {
_ = r.Close()
return nil, fmt.Errorf("engine: scan dictionary dir %s: %w", dir, err)
}
for _, e := range entries {
if !e.IsDir() || e.Name() == bootVersion {
continue
}
if _, err := r.LoadAvailable(filepath.Join(dir, e.Name()), e.Name()); err != nil {
_ = r.Close()
return nil, err
}
}
return r, nil
}
// Load reads the committed DAWG of variant from dir, builds a solver over it and
// registers it under version. Reloading the same (variant, version) replaces the
// previous entry, closing its finder. The most recently loaded version of a
@@ -91,6 +122,29 @@ func (r *Registry) Load(v Variant, version, dir string) error {
return nil
}
// LoadAvailable loads, under version, every variant whose committed DAWG is
// present in dir, skipping a variant whose file is absent. It backs the admin
// dictionary reload (a version subdirectory may carry only the variants that were
// rebuilt) and OpenWithVersions' boot-time scan. It returns the variants it
// loaded, in catalogue order, or the first load error.
func (r *Registry) LoadAvailable(dir, version string) ([]Variant, error) {
var loaded []Variant
for _, v := range Variants() {
path := filepath.Join(dir, dictFiles[v])
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
continue
}
return loaded, fmt.Errorf("engine: stat %s dictionary %q in %s: %w", v, version, dir, err)
}
if err := r.Load(v, version, dir); err != nil {
return loaded, err
}
loaded = append(loaded, v)
}
return loaded, nil
}
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
// when the variant is absent and ErrUnknownVersion when only the version is.
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"errors"
"testing"
"scrabble-solver/board"
"scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
)
// TestRegistryOpensEveryVariant checks that Open loads all three variants at the
+119
View File
@@ -0,0 +1,119 @@
package engine
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
)
// copyDawg copies the committed DAWG for v from srcDir into dstDir (creating
// dstDir). It is the fixture builder for the dictionary-reload tests, which need
// real DAWG files laid out in temporary version directories.
func copyDawg(t *testing.T, srcDir, dstDir string, v Variant) {
t.Helper()
if err := os.MkdirAll(dstDir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dstDir, err)
}
name := dictFiles[v]
src, err := os.Open(filepath.Join(srcDir, name))
if err != nil {
t.Fatalf("open source dawg %s: %v", name, err)
}
defer func() { _ = src.Close() }()
dst, err := os.Create(filepath.Join(dstDir, name))
if err != nil {
t.Fatalf("create dest dawg %s: %v", name, err)
}
defer func() { _ = dst.Close() }()
if _, err := io.Copy(dst, src); err != nil {
t.Fatalf("copy dawg %s: %v", name, err)
}
}
// TestLoadAvailableLoadsPresentSkipsAbsent verifies LoadAvailable registers only
// the variants whose DAWG is present in the directory, under the given version.
func TestLoadAvailableLoadsPresentSkipsAbsent(t *testing.T) {
dir := t.TempDir()
copyDawg(t, testDictDir(), dir, VariantEnglish) // only English present
reg := NewRegistry()
defer func() { _ = reg.Close() }()
loaded, err := reg.LoadAvailable(dir, "v2")
if err != nil {
t.Fatalf("load available: %v", err)
}
if len(loaded) != 1 || loaded[0] != VariantEnglish {
t.Fatalf("loaded = %v, want [english]", loaded)
}
if _, err := reg.Solver(VariantEnglish, "v2"); err != nil {
t.Errorf("english v2 solver: %v", err)
}
if _, err := reg.Solver(VariantRussianScrabble, "v2"); !errors.Is(err, ErrUnknownVariant) {
t.Errorf("russian v2 should be absent: got %v", err)
}
}
// TestOpenWithVersionsScansSubdirs verifies the boot helper loads the flat boot
// version plus every version subdirectory, the subdir version becoming the
// variant's latest while the boot version stays resident.
func TestOpenWithVersionsScansSubdirs(t *testing.T) {
dir := t.TempDir()
for _, v := range Variants() { // flat boot version: all three variants
copyDawg(t, testDictDir(), dir, v)
}
copyDawg(t, testDictDir(), filepath.Join(dir, "v2"), VariantEnglish) // v2 subdir: English only
reg, err := OpenWithVersions(dir, "v1")
if err != nil {
t.Fatalf("open with versions: %v", err)
}
defer func() { _ = reg.Close() }()
for _, v := range Variants() {
if _, err := reg.Solver(v, "v1"); err != nil {
t.Errorf("boot solver %s/v1: %v", v, err)
}
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Errorf("english versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest english: %v", err)
}
if latest != "v2" {
t.Errorf("latest english = %q, want v2", latest)
}
if got := reg.Versions(VariantRussianScrabble); len(got) != 1 {
t.Errorf("russian versions = %v, want one (no v2 file)", got)
}
}
// TestReloadRegistersNewVersion verifies Load adds a second version to a variant
// already resident, moves the latest pointer and keeps the earlier version.
func TestReloadRegistersNewVersion(t *testing.T) {
reg, err := Open(testDictDir(), "v1", VariantEnglish)
if err != nil {
t.Fatalf("open: %v", err)
}
defer func() { _ = reg.Close() }()
if err := reg.Load(VariantEnglish, "v2", testDictDir()); err != nil {
t.Fatalf("reload v2: %v", err)
}
if got := reg.Versions(VariantEnglish); len(got) != 2 {
t.Fatalf("versions = %v, want two", got)
}
latest, _, err := reg.Latest(VariantEnglish)
if err != nil {
t.Fatalf("latest: %v", err)
}
if latest != "v2" {
t.Errorf("latest = %q, want v2", latest)
}
if _, err := reg.Solver(VariantEnglish, "v1"); err != nil {
t.Errorf("v1 still resident: %v", err)
}
}
+18 -3
View File
@@ -63,6 +63,7 @@ type gameCache struct {
type cachedGame struct {
game *engine.Game
variant string
lastAccess time.Time
}
@@ -82,11 +83,12 @@ func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
return e.game, true
}
// put stores g as the live game for id.
func (c *gameCache) put(id uuid.UUID, g *engine.Game) {
// put stores g as the live game for id. variant labels the entry so the active-
// games gauge can report counts by variant without inspecting engine internals.
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[id] = &cachedGame{game: g, lastAccess: c.now()}
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
}
// remove drops id from the cache (used on a finished game and after a failed
@@ -119,3 +121,16 @@ func (c *gameCache) size() int {
defer c.mu.Unlock()
return len(c.entries)
}
// countByVariant tallies the resident games by their variant label. It backs the
// game_cache_active observable gauge; the resident set is the bounded number of
// live (active) games, so the scan under the lock is cheap.
func (c *gameCache) countByVariant() map[string]int {
c.mu.Lock()
defer c.mu.Unlock()
out := make(map[string]int, len(c.entries))
for _, e := range c.entries {
out[e.variant]++
}
return out
}
+1 -1
View File
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
cur := time.Unix(1_700_000_000, 0)
cache := newGameCache(time.Hour, func() time.Time { return cur })
id := uuid.New()
cache.put(id, nil)
cache.put(id, nil, "english")
if _, ok := cache.get(id); !ok {
t.Fatal("game must be resident after put")
}
+109
View File
@@ -0,0 +1,109 @@
package game
import (
"context"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
"go.uber.org/zap"
"scrabble/backend/internal/engine"
)
// meterName scopes the game domain's OpenTelemetry instruments.
const meterName = "scrabble/backend/game"
// gameMetrics holds the game domain's operational instruments. Every game-scoped
// measurement carries a "variant" attribute (english/russian/erudit). The
// instruments default to no-ops (see defaultGameMetrics), so recording is always
// safe; SetMetrics installs the real meter during startup wiring.
type gameMetrics struct {
replay metric.Float64Histogram
validate metric.Float64Histogram
started metric.Int64Counter
abandoned metric.Int64Counter
}
// defaultGameMetrics returns instruments backed by a no-op meter, recording
// nothing until SetMetrics installs a real one.
func defaultGameMetrics() *gameMetrics {
return newGameMetrics(noop.NewMeterProvider().Meter(meterName))
}
// newGameMetrics builds the instruments on meter, falling back to no-op
// instruments on the (rare) construction error so the game domain never fails to
// start over telemetry.
func newGameMetrics(meter metric.Meter) *gameMetrics {
return &gameMetrics{
replay: histogram(meter, "game_replay_duration", "Seconds to rebuild a live game from its journal on a cache miss."),
validate: histogram(meter, "game_move_validate_duration", "Seconds to validate and score a tentative play (EvaluatePlay)."),
started: counter(meter, "games_started_total", "Games created and started."),
abandoned: counter(meter, "games_abandoned_total", "Player seats dropped by the turn-timeout sweeper."),
}
}
// SetMetrics installs the meter the game domain records to and registers the
// observable gauge reporting the live games resident in the cache by variant. It
// must be called during startup wiring; the default is a no-op meter.
func (svc *Service) SetMetrics(meter metric.Meter) {
if meter == nil {
return
}
svc.metrics = newGameMetrics(meter)
if _, err := meter.Int64ObservableGauge("game_cache_active",
metric.WithDescription("Live games currently resident in the in-memory cache, by variant."),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
for variant, n := range svc.cache.countByVariant() {
o.Observe(int64(n), metric.WithAttributes(attribute.String("variant", variant)))
}
return nil
}),
); err != nil {
svc.log.Warn("game: register cache gauge", zap.Error(err))
}
}
// recordReplay records the duration of a cache-miss journal replay for variant.
func (m *gameMetrics) recordReplay(ctx context.Context, v engine.Variant, start time.Time) {
m.replay.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
}
// recordValidate records the duration of one play validation for variant.
func (m *gameMetrics) recordValidate(ctx context.Context, v engine.Variant, start time.Time) {
m.validate.Record(ctx, time.Since(start).Seconds(), variantAttr(v))
}
// recordStarted counts one started game of variant.
func (m *gameMetrics) recordStarted(ctx context.Context, v engine.Variant) {
m.started.Add(ctx, 1, variantAttr(v))
}
// recordAbandoned counts one seat dropped by the turn-timeout sweeper in a game of
// variant.
func (m *gameMetrics) recordAbandoned(ctx context.Context, v engine.Variant) {
m.abandoned.Add(ctx, 1, variantAttr(v))
}
// variantAttr is the shared "variant" attribute option, usable for both Record and
// Add measurements.
func variantAttr(v engine.Variant) metric.MeasurementOption {
return metric.WithAttributes(attribute.String("variant", v.String()))
}
func histogram(m metric.Meter, name, desc string) metric.Float64Histogram {
h, err := m.Float64Histogram(name, metric.WithUnit("s"), metric.WithDescription(desc))
if err != nil {
h, _ = noop.NewMeterProvider().Meter(meterName).Float64Histogram(name)
}
return h
}
func counter(m metric.Meter, name, desc string) metric.Int64Counter {
c, err := m.Int64Counter(name, metric.WithDescription(desc))
if err != nil {
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter(name)
}
return c
}
+95
View File
@@ -0,0 +1,95 @@
package game
import (
"context"
"testing"
"time"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"scrabble/backend/internal/engine"
)
// TestGameMetrics records each game instrument through a manual reader and asserts
// the counters carry the right "variant" attribute and the histograms observe.
func TestGameMetrics(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newGameMetrics(meter)
m.recordStarted(ctx, engine.VariantEnglish)
m.recordStarted(ctx, engine.VariantEnglish)
m.recordStarted(ctx, engine.VariantRussianScrabble)
m.recordAbandoned(ctx, engine.VariantErudit)
m.recordReplay(ctx, engine.VariantEnglish, time.Now().Add(-time.Millisecond))
m.recordValidate(ctx, engine.VariantRussianScrabble, time.Now().Add(-time.Millisecond))
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
started := counterByAttr(t, rm, "games_started_total", "variant")
if started["english"] != 2 || started["russian_scrabble"] != 1 {
t.Errorf("games_started_total = %v, want english:2 russian_scrabble:1", started)
}
if abandoned := counterByAttr(t, rm, "games_abandoned_total", "variant"); abandoned["erudit"] != 1 {
t.Errorf("games_abandoned_total = %v, want erudit:1", abandoned)
}
if c := histogramCount(t, rm, "game_replay_duration"); c != 1 {
t.Errorf("game_replay_duration observations = %d, want 1", c)
}
if c := histogramCount(t, rm, "game_move_validate_duration"); c != 1 {
t.Errorf("game_move_validate_duration observations = %d, want 1", c)
}
}
// counterByAttr sums the int64 counter named name, grouped by the value of the
// attribute key attr.
func counterByAttr(t *testing.T, rm metricdata.ResourceMetrics, name, attr string) map[string]int64 {
t.Helper()
out := map[string]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != name {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("%s is not an int64 sum", name)
}
for _, dp := range sum.DataPoints {
v, _ := dp.Attributes.Value(attribute.Key(attr))
out[v.AsString()] += dp.Value
}
}
}
return out
}
// histogramCount returns the total observation count of the float64 histogram
// named name.
func histogramCount(t *testing.T, rm metricdata.ResourceMetrics, name string) uint64 {
t.Helper()
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != name {
continue
}
h, ok := md.Data.(metricdata.Histogram[float64])
if !ok {
t.Fatalf("%s is not a float64 histogram", name)
}
var n uint64
for _, dp := range h.DataPoints {
n += dp.Count
}
return n
}
}
t.Fatalf("%s not found", name)
return 0
}
+118 -2
View File
@@ -33,6 +33,7 @@ type Service struct {
clock func() time.Time
rng func() int64
pub notify.Publisher
metrics *gameMetrics
log *zap.Logger
}
@@ -51,6 +52,7 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
clock: clock,
rng: randomSeed,
pub: notify.Nop{},
metrics: defaultGameMetrics(),
log: log,
}
}
@@ -135,7 +137,8 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
return Game{}, err
}
svc.cache.put(id, g)
svc.cache.put(id, g, params.Variant.String())
svc.metrics.recordStarted(ctx, params.Variant)
return svc.store.GetGame(ctx, id)
}
@@ -175,6 +178,13 @@ func (svc *Service) Resign(ctx context.Context, gameID, accountID uuid.UUID) (Mo
})
}
// GameVariant returns just a game's variant. The edge layer uses it to map wire alphabet
// indices to concrete letters before delegating to the letter-based play, exchange and
// word-check methods (Stage 13), keeping a single domain path shared with the robot.
func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.Variant, error) {
return svc.store.GetGameVariant(ctx, gameID)
}
// transition validates the actor and turn, applies op under the per-game lock and
// commits the result.
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
@@ -350,6 +360,7 @@ func (svc *Service) timeoutGame(ctx context.Context, gameID uuid.UUID, now time.
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
return false, err
}
svc.metrics.recordAbandoned(ctx, cur.Variant)
return true, nil
}
@@ -373,7 +384,9 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
if err != nil {
return EvalResult{}, err
}
validateStart := time.Now()
rec, err := g.EvaluatePlay(dir, tiles)
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
if err != nil {
if errors.Is(err, engine.ErrIllegalPlay) {
return EvalResult{Valid: false}, nil
@@ -420,6 +433,68 @@ func (svc *Service) FileComplaint(ctx context.Context, gameID, accountID uuid.UU
})
}
// ListComplaints returns word-check complaints for the admin review queue,
// newest first. status filters by lifecycle state ("" = all); limit is clamped
// to a sane page size and offset is floored at zero.
func (svc *Service) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
return svc.store.ListComplaints(ctx, status, clampPageSize(limit), max(0, offset))
}
// GetComplaint loads a single complaint for the admin detail view.
func (svc *Service) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
return svc.store.GetComplaint(ctx, id)
}
// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (svc *Service) CountComplaints(ctx context.Context, status string) (int, error) {
return svc.store.CountComplaints(ctx, status)
}
// ResolveComplaint closes a complaint with an operator disposition (reject /
// accept_add / accept_remove) and an optional note. An accepted complaint then
// appears in DictionaryChanges until a rebuilt dictionary is loaded and the
// change is marked applied. It returns ErrInvalidConfig for an unknown
// disposition and ErrNotFound when no complaint matches.
func (svc *Service) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string) (Complaint, error) {
if !validDisposition(disposition) {
return Complaint{}, fmt.Errorf("%w: complaint disposition %q", ErrInvalidConfig, disposition)
}
return svc.store.ResolveComplaint(ctx, id, disposition, note, svc.clock())
}
// DictionaryChanges returns the pending wordlist edits implied by resolved,
// accepted complaints not yet marked applied — the input to the offline DAWG
// rebuild.
func (svc *Service) DictionaryChanges(ctx context.Context) ([]DictionaryChange, error) {
rows, err := svc.store.ListDictionaryChanges(ctx)
if err != nil {
return nil, err
}
out := make([]DictionaryChange, 0, len(rows))
for _, c := range rows {
ch := DictionaryChange{
ComplaintID: c.ID,
Variant: c.Variant,
Word: c.Word,
Add: c.Disposition == DispositionAcceptAdd,
Note: c.Note,
}
if c.ResolvedAt != nil {
ch.ResolvedAt = *c.ResolvedAt
}
out = append(out, ch)
}
return out, nil
}
// MarkChangesApplied records that every pending accepted change for variant has
// been folded into the dictionary version that was just hot-reloaded, removing
// them from DictionaryChanges. It returns the number of changes marked.
func (svc *Service) MarkChangesApplied(ctx context.Context, variant engine.Variant, version string) (int64, error) {
return svc.store.MarkChangesApplied(ctx, variant.String(), version)
}
// Hint reveals the top-scoring legal play for the requesting player on their
// turn, spending one hint from their per-game allowance and then their profile
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
@@ -583,6 +658,23 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
return svc.store.ListGamesForAccount(ctx, accountID)
}
// GameByID returns a game with its seats for the admin console detail view.
func (svc *Service) GameByID(ctx context.Context, id uuid.UUID) (Game, error) {
return svc.store.GetGame(ctx, id)
}
// ListGames returns games for the admin list, newest-updated first, paginated,
// optionally filtered by status.
func (svc *Service) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
return svc.store.ListGames(ctx, status, clampPageSize(limit), max(0, offset))
}
// CountGames returns the game count, optionally filtered by status, for the admin
// list pager and dashboard.
func (svc *Service) CountGames(ctx context.Context, status string) (int, error) {
return svc.store.CountGames(ctx, status)
}
// History returns a game's full, dictionary-independent move journal.
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
g, err := svc.store.GetGame(ctx, gameID)
@@ -625,7 +717,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
return nil, err
}
if !g.Over() {
svc.cache.put(pre.ID, g)
svc.cache.put(pre.ID, g, pre.Variant.String())
}
return g, nil
}
@@ -634,6 +726,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
// re-applying every journalled move in order. The deterministic bag makes the
// reconstruction exact.
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
defer svc.metrics.recordReplay(ctx, pre.Variant, time.Now())
seed, err := svc.store.GameSeed(ctx, pre.ID)
if err != nil {
return nil, err
@@ -770,6 +863,29 @@ func normalizeWord(word string) string {
return strings.ToLower(strings.TrimSpace(word))
}
// validDisposition reports whether d is an accepted complaint disposition.
func validDisposition(d string) bool {
switch d {
case DispositionReject, DispositionAcceptAdd, DispositionAcceptRemove:
return true
default:
return false
}
}
// clampPageSize bounds an admin list page size to [1, 200], defaulting an unset
// (non-positive) request to 50.
func clampPageSize(limit int) int {
switch {
case limit <= 0:
return 50
case limit > 200:
return 200
default:
return limit
}
}
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
// system source fails.
func randomSeed() int64 {
+198
View File
@@ -135,6 +135,24 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
return projectGame(grow, srows)
}
// GetGameVariant reads just a game's variant — a cheap single-column lookup the edge uses
// to map wire alphabet indices to concrete letters (Stage 13) without loading the whole
// game and its seats.
func (s *Store) GetGameVariant(ctx context.Context, id uuid.UUID) (engine.Variant, error) {
stmt := postgres.SELECT(table.Games.Variant).
FROM(table.Games).
WHERE(table.Games.GameID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("game: get variant %s: %w", id, err)
}
return engine.ParseVariant(row.Variant)
}
// SharedGameExists reports whether accounts a and b are both seated in at least
// one game (active or finished). It backs the social package's "befriend an
// opponent" gate via a self-join on game_players.
@@ -197,6 +215,53 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil
}
// ListGames returns games for the admin games list, most-recently-updated first,
// paginated. status filters by lifecycle ("active"/"finished") when non-empty.
// The seats are not loaded — the list shows summaries; the detail view uses
// GetGame.
func (s *Store) ListGames(ctx context.Context, status string, limit, offset int) ([]Game, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Games.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(table.Games.AllColumns).
FROM(table.Games).
WHERE(where).
ORDER_BY(table.Games.UpdatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list games: %w", err)
}
out := make([]Game, 0, len(rows))
for _, g := range rows {
pg, err := projectGame(g, nil)
if err != nil {
return nil, err
}
out = append(out, pg)
}
return out, nil
}
// CountGames returns the number of games, optionally restricted to a status, for
// admin-list pagination.
func (s *Store) CountGames(ctx context.Context, status string) (int, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Games.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(postgres.COUNT(table.Games.GameID).AS("count")).
FROM(table.Games).
WHERE(where)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count games: %w", err)
}
return int(dest.Count), nil
}
// GetJournal loads the ordered, decoded move journal for a game.
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
stmt := postgres.SELECT(table.GameMoves.AllColumns).
@@ -384,6 +449,122 @@ func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, erro
return projectComplaint(row)
}
// ListComplaints returns complaints for the admin review queue, newest first.
// status filters by lifecycle state when non-empty; limit and offset paginate.
func (s *Store) ListComplaints(ctx context.Context, status string, limit, offset int) ([]Complaint, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Complaints.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(where).
ORDER_BY(table.Complaints.CreatedAt.DESC()).
LIMIT(int64(limit)).
OFFSET(int64(offset))
var rows []model.Complaints
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list complaints: %w", err)
}
return projectComplaints(rows)
}
// GetComplaint loads one complaint by id, or ErrNotFound.
func (s *Store) GetComplaint(ctx context.Context, id uuid.UUID) (Complaint, error) {
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
LIMIT(1)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Complaint{}, ErrNotFound
}
return Complaint{}, fmt.Errorf("game: get complaint %s: %w", id, err)
}
return projectComplaint(row)
}
// ResolveComplaint closes a complaint with a disposition and note, stamping
// resolved_at, and returns the updated row (ErrNotFound when none matches). It
// leaves applied_in_version untouched.
func (s *Store) ResolveComplaint(ctx context.Context, id uuid.UUID, disposition, note string, now time.Time) (Complaint, error) {
stmt := table.Complaints.UPDATE(
table.Complaints.Status, table.Complaints.Disposition,
table.Complaints.ResolutionNote, table.Complaints.ResolvedAt,
).SET(
postgres.String(StatusComplaintResolved), postgres.String(disposition),
postgres.String(note), postgres.TimestampzT(now),
).WHERE(table.Complaints.ComplaintID.EQ(postgres.UUID(id))).
RETURNING(table.Complaints.AllColumns)
var row model.Complaints
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Complaint{}, ErrNotFound
}
return Complaint{}, fmt.Errorf("game: resolve complaint %s: %w", id, err)
}
return projectComplaint(row)
}
// ListDictionaryChanges returns the resolved, accepted complaints not yet marked
// applied (the pending wordlist edits), ordered by variant then resolution time.
func (s *Store) ListDictionaryChanges(ctx context.Context) ([]Complaint, error) {
stmt := postgres.SELECT(table.Complaints.AllColumns).
FROM(table.Complaints).
WHERE(
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
AND(table.Complaints.Disposition.IN(
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
)).
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
).
ORDER_BY(table.Complaints.Variant.ASC(), table.Complaints.ResolvedAt.ASC())
var rows []model.Complaints
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("game: list dictionary changes: %w", err)
}
return projectComplaints(rows)
}
// MarkChangesApplied stamps every pending accepted change for variant with
// version (so it drops out of ListDictionaryChanges) and returns the count.
func (s *Store) MarkChangesApplied(ctx context.Context, variant, version string) (int64, error) {
stmt := table.Complaints.UPDATE(table.Complaints.AppliedInVersion).
SET(postgres.String(version)).
WHERE(
table.Complaints.Status.EQ(postgres.String(StatusComplaintResolved)).
AND(table.Complaints.Variant.EQ(postgres.String(variant))).
AND(table.Complaints.Disposition.IN(
postgres.String(DispositionAcceptAdd), postgres.String(DispositionAcceptRemove),
)).
AND(table.Complaints.AppliedInVersion.EQ(postgres.String(""))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("game: mark changes applied: %w", err)
}
n, _ := res.RowsAffected()
return n, nil
}
// CountComplaints returns the number of complaints, optionally restricted to a
// status, for the admin queue pager and the dashboard counts.
func (s *Store) CountComplaints(ctx context.Context, status string) (int, error) {
where := postgres.Bool(true)
if status != "" {
where = table.Complaints.Status.EQ(postgres.String(status))
}
stmt := postgres.SELECT(postgres.COUNT(table.Complaints.ComplaintID).AS("count")).
FROM(table.Complaints).
WHERE(where)
var dest struct{ Count int64 }
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("game: count complaints: %w", err)
}
return int(dest.Count), nil
}
// ActiveGames returns the turn clocks of every in-progress game; the sweeper
// filters them against the per-move deadline and the player's away window.
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
@@ -533,9 +714,26 @@ func projectComplaint(row model.Complaints) (Complaint, error) {
Note: row.Note,
Status: row.Status,
CreatedAt: row.CreatedAt,
Disposition: row.Disposition,
ResolutionNote: row.ResolutionNote,
ResolvedAt: row.ResolvedAt,
AppliedInVersion: row.AppliedInVersion,
}, nil
}
// projectComplaints projects a slice of complaint rows, preserving order.
func projectComplaints(rows []model.Complaints) ([]Complaint, error) {
out := make([]Complaint, 0, len(rows))
for _, r := range rows {
c, err := projectComplaint(r)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, nil
}
// withTx wraps fn in a transaction, committing on nil and rolling back on error.
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
+40 -4
View File
@@ -15,9 +15,23 @@ const (
StatusFinished = "finished"
)
// ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only
// ever writes StatusComplaintOpen.
const StatusComplaintOpen = "open"
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3)
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a
// Disposition. The CHECK constraints live in migration 00008.
const (
StatusComplaintOpen = "open"
StatusComplaintResolved = "resolved"
)
// Complaint dispositions chosen at resolution. DispositionReject keeps the
// dictionary as-is; DispositionAcceptAdd / DispositionAcceptRemove mark the word
// for addition to / removal from the variant's wordlist and feed the offline
// dictionary-rebuild pipeline (see DictionaryChange).
const (
DispositionReject = "reject"
DispositionAcceptAdd = "accept_add"
DispositionAcceptRemove = "accept_remove"
)
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
@@ -179,7 +193,9 @@ type RobotTurn struct {
Seed int64
}
// Complaint is a word-check complaint awaiting admin review (Stage 10).
// Complaint is a word-check complaint in the admin review queue. It is filed
// against a game's pinned (Variant, DictVersion) with the lookup result at filing
// time (WasValid); the resolution fields stay empty until an operator resolves it.
type Complaint struct {
ID uuid.UUID
ComplainantID uuid.UUID
@@ -191,4 +207,24 @@ type Complaint struct {
Note string
Status string
CreatedAt time.Time
// Resolution fields, set when Status == StatusComplaintResolved.
Disposition string // "" while open; otherwise a Disposition* value
ResolutionNote string // operator note recorded at resolution
ResolvedAt *time.Time // nil while open
AppliedInVersion string // dict version an accepted change was folded into ("" = pending)
}
// DictionaryChange is the wordlist edit implied by one resolved, accepted
// complaint: Add reports whether Word should be added (DispositionAcceptAdd) or
// removed (DispositionAcceptRemove) for Variant. The admin console lists the
// pending changes as the input to the offline DAWG rebuild; once a rebuilt
// dictionary version is hot-reloaded they are marked applied.
type DictionaryChange struct {
ComplaintID uuid.UUID
Variant engine.Variant
Word string
Add bool
ResolvedAt time.Time
Note string
}
+150
View File
@@ -104,3 +104,153 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
}
return confirmed
}
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields,
// defaults the in-app-only flag on, and never overwrites an existing account on a
// later login (Stage 9 language seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if acc.PreferredLanguage != "ru" {
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
}
if acc.DisplayName != "Иван" {
t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
}
if !acc.NotificationsInAppOnly {
t.Error("NotificationsInAppOnly should default to true")
}
// A later login with different fields returns the same account, unchanged.
again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
if err != nil {
t.Fatalf("re-provision telegram: %v", err)
}
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
}
}
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
// client language falls back to the account default rather than failing the
// language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background()
acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if acc.PreferredLanguage != "en" {
t.Errorf("PreferredLanguage = %q, want default en", acc.PreferredLanguage)
}
}
// TestServiceLanguageRoundTrip checks SetServiceLanguage persists the push-routing
// language (the bot a Telegram user last signed in through): a fresh account has
// none, a set value reads back, a later login overwrites it (last-login-wins), and
// an empty value is a no-op. The push-target route coalesces it with the preferred
// language.
func TestServiceLanguageRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if acc.ServiceLanguage != "" {
t.Errorf("fresh ServiceLanguage = %q, want empty", acc.ServiceLanguage)
}
if err := store.SetServiceLanguage(ctx, acc.ID, "ru"); err != nil {
t.Fatalf("set service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "ru" {
t.Errorf("ServiceLanguage = %q, want ru", got.ServiceLanguage)
}
// A later login through the other bot updates it; a subsequent empty value
// (a non-Telegram login) leaves it unchanged.
if err := store.SetServiceLanguage(ctx, acc.ID, "en"); err != nil {
t.Fatalf("update service language: %v", err)
}
if err := store.SetServiceLanguage(ctx, acc.ID, ""); err != nil {
t.Fatalf("noop service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "en" {
t.Errorf("ServiceLanguage after update+noop = %q, want en", got.ServiceLanguage)
}
}
// TestIdentityExternalID covers the reverse identity lookup the push-target route
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
// including for a guest that carries no identity.
func TestIdentityExternalID(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
got, err := store.IdentityExternalID(ctx, acc.ID, account.KindTelegram)
if err != nil {
t.Fatalf("identity external id: %v", err)
}
if got != ext {
t.Errorf("external id = %q, want %q", got, ext)
}
if _, err := store.IdentityExternalID(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrNotFound) {
t.Errorf("email lookup = %v, want ErrNotFound", err)
}
guest := provisionGuest(t)
if _, err := store.IdentityExternalID(ctx, guest, account.KindTelegram); !errors.Is(err, account.ErrNotFound) {
t.Errorf("guest lookup = %v, want ErrNotFound", err)
}
}
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
// through UpdateProfile and reads back through GetByID.
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if !acc.NotificationsInAppOnly {
t.Fatal("default should be in-app-only true")
}
updated, err := store.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
DisplayName: "Player",
PreferredLanguage: "en",
TimeZone: "UTC",
NotificationsInAppOnly: false,
})
if err != nil {
t.Fatalf("update profile: %v", err)
}
if updated.NotificationsInAppOnly {
t.Error("update did not clear NotificationsInAppOnly")
}
got, err := store.GetByID(ctx, acc.ID)
if err != nil {
t.Fatalf("get by id: %v", err)
}
if got.NotificationsInAppOnly {
t.Error("GetByID still reports in-app-only after clearing")
}
}
+183
View File
@@ -0,0 +1,183 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/server"
)
// TestComplaintResolutionPipeline drives a complaint from filing through
// resolution into the dictionary-change pipeline and on to "applied".
func TestComplaintResolutionPipeline(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
if err != nil {
t.Fatalf("create: %v", err)
}
word := "zzzzzz" // a non-word the filer thinks should be valid → an accept_add candidate
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], word, "please add")
if err != nil {
t.Fatalf("file: %v", err)
}
if open, _ := svc.CountComplaints(ctx, game.StatusComplaintOpen); open < 1 {
t.Fatalf("open complaints = %d, want >= 1", open)
}
list, err := svc.ListComplaints(ctx, game.StatusComplaintOpen, 100, 0)
if err != nil || !containsComplaint(list, filed.ID) {
t.Fatalf("open list missing filed complaint (err %v)", err)
}
resolved, err := svc.ResolveComplaint(ctx, filed.ID, game.DispositionAcceptAdd, "agreed")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if resolved.Status != game.StatusComplaintResolved || resolved.Disposition != game.DispositionAcceptAdd || resolved.ResolvedAt == nil {
t.Fatalf("unexpected resolved complaint: %+v", resolved)
}
changes, err := svc.DictionaryChanges(ctx)
if err != nil {
t.Fatalf("changes: %v", err)
}
if !changeFor(changes, word, true) {
t.Fatalf("dictionary changes missing add %q: %+v", word, changes)
}
n, err := svc.MarkChangesApplied(ctx, engine.VariantEnglish, "v2")
if err != nil || n < 1 {
t.Fatalf("mark applied n=%d err=%v", n, err)
}
if after, err := svc.DictionaryChanges(ctx); err != nil || changeFor(after, word, true) {
t.Fatalf("change still pending after apply (err %v): %+v", err, after)
}
}
func containsComplaint(list []game.Complaint, id uuid.UUID) bool {
for _, c := range list {
if c.ID == id {
return true
}
}
return false
}
func changeFor(changes []game.DictionaryChange, word string, add bool) bool {
for _, c := range changes {
if c.Word == word && c.Add == add {
return true
}
}
return false
}
// TestAdminListsAndCounts checks the admin read queries and their COUNT scans.
func TestAdminListsAndCounts(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
svc := newGameService()
accBefore, err := store.CountAccounts(ctx)
if err != nil {
t.Fatalf("count accounts: %v", err)
}
a, b := provisionAccount(t), provisionAccount(t)
accAfter, err := store.CountAccounts(ctx)
if err != nil {
t.Fatalf("count accounts: %v", err)
}
if accAfter < accBefore+2 {
t.Errorf("account count did not grow by 2: %d -> %d", accBefore, accAfter)
}
if page, err := store.ListAccounts(ctx, 1, 0); err != nil || len(page) != 1 {
t.Fatalf("list accounts page size 1 = %d (err %v)", len(page), err)
}
if ids, err := store.Identities(ctx, a); err != nil || len(ids) != 1 || ids[0].Kind != account.KindTelegram {
t.Fatalf("identities for a = %+v (err %v)", ids, err)
}
gBefore, _ := svc.CountGames(ctx, "")
if _, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 2}); err != nil {
t.Fatalf("create: %v", err)
}
if gAfter, _ := svc.CountGames(ctx, ""); gAfter != gBefore+1 {
t.Errorf("game count %d -> %d, want +1", gBefore, gAfter)
}
if active, err := svc.ListGames(ctx, game.StatusActive, 100, 0); err != nil || len(active) == 0 {
t.Fatalf("list active games = %d (err %v)", len(active), err)
}
}
// TestConsoleServesAndGuardsCSRF drives the /_gm console over HTTP against real
// stores: pages render, and a state-changing POST needs a same-origin header.
func TestConsoleServesAndGuardsCSRF(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 3})
if err != nil {
t.Fatalf("create: %v", err)
}
filed, err := svc.FileComplaint(ctx, g.ID, seats[0], "qwxz", "review")
if err != nil {
t.Fatalf("file: %v", err)
}
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(),
Accounts: account.NewStore(testDB),
Games: svc,
Registry: testRegistry,
DictDir: dictDir(),
})
h := srv.Handler()
base := "http://admin.test/_gm"
if code, body := consoleDo(h, http.MethodGet, base+"/", "", ""); code != http.StatusOK || !strings.Contains(body, "Dashboard") {
t.Fatalf("dashboard = %d, has Dashboard=%v", code, strings.Contains(body, "Dashboard"))
}
if code, body := consoleDo(h, http.MethodGet, base+"/complaints/"+filed.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "qwxz") {
t.Fatalf("complaint detail = %d, has word=%v", code, strings.Contains(body, "qwxz"))
}
// A resolve POST without a same-origin header is rejected by the CSRF guard.
if code, _ := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject", ""); code != http.StatusForbidden {
t.Fatalf("resolve without origin = %d, want 403", code)
}
// With a matching Origin it succeeds and persists.
if code, body := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject&note=ok", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Resolved") {
t.Fatalf("resolve with origin = %d, has Resolved=%v", code, strings.Contains(body, "Resolved"))
}
if got, err := svc.GetComplaint(ctx, filed.ID); err != nil || got.Status != game.StatusComplaintResolved {
t.Fatalf("complaint not resolved: %+v (err %v)", got, err)
}
}
// consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
req := httptest.NewRequest(method, target, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if origin != "" {
req.Header.Set("Origin", origin)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code, rec.Body.String()
}
+27
View File
@@ -312,6 +312,13 @@ func TestTimeoutSweep(t *testing.T) {
}
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
// Disable the to-move account's away window: with the default 00:0007:00
// window the sweeper (correctly) declines to time out a player whose deadline
// fell while they were asleep, which made this test fail whenever CI ran with
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
// deterministic regardless of the time of day.
setAway(t, seats[0], "UTC", "00:00", "00:00")
// The sweep is global over the shared pool; assert the target game itself,
// not the count, since other tests leave active games behind.
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
@@ -421,6 +428,26 @@ func TestHintPolicy(t *testing.T) {
}
}
// TestGameVariant covers the edge's lightweight variant lookup (Stage 13): it returns the
// created game's variant and ErrNotFound for an unknown id.
func TestGameVariant(t *testing.T) {
ctx := context.Background()
svc := newGameService()
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := svc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: 1,
})
if err != nil {
t.Fatalf("create: %v", err)
}
if v, err := svc.GameVariant(ctx, g.ID); err != nil || v != engine.VariantEnglish {
t.Fatalf("GameVariant = %v, %v; want english, nil", v, err)
}
if _, err := svc.GameVariant(ctx, uuid.New()); !errors.Is(err, game.ErrNotFound) {
t.Errorf("GameVariant(unknown) = %v, want ErrNotFound", err)
}
}
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
func TestCheckWordAndComplaint(t *testing.T) {
ctx := context.Background()
@@ -0,0 +1,76 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// TestGuestReaper verifies the abandoned-guest reaper: it deletes guests with no
// game seat once their account age is past the cutoff, while sparing guests that
// are too young, guests seated in a game (the FK-protected opponent history), and
// durable accounts.
func TestGuestReaper(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
guestA := provisionGuest(t) // guest, no seat → reaped on a future cutoff
guestB := provisionGuest(t) // guest, no seat → reaped on a future cutoff
seated := provisionGuest(t) // guest seated in a game → kept
durable := provisionAccount(t)
// Seat the third guest in a game with a durable opponent (Create needs 2-4).
opp := provisionAccount(t)
if _, err := newGameService().Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: []uuid.UUID{seated, opp}, TurnTimeout: 24 * time.Hour, Seed: 1,
}); err != nil {
t.Fatalf("create game: %v", err)
}
// A cutoff in the past: every account is younger than the window, so the age
// gate spares them all.
if n, err := store.ReapAbandonedGuests(ctx, time.Now().Add(-time.Hour)); err != nil {
t.Fatalf("reap (past cutoff): %v", err)
} else if n != 0 {
t.Fatalf("reap with a past cutoff deleted %d, want 0", n)
}
assertAccount(t, store, guestA, true)
// A cutoff in the future: every account predates it, so the no-seat guests are
// reaped and the seated guest and the durable account survive.
if _, err := store.ReapAbandonedGuests(ctx, time.Now().Add(time.Hour)); err != nil {
t.Fatalf("reap (future cutoff): %v", err)
}
assertAccount(t, store, guestA, false)
assertAccount(t, store, guestB, false)
assertAccount(t, store, seated, true)
assertAccount(t, store, durable, true)
}
// assertAccount checks whether the account with id is present, failing the test
// when its presence differs from want.
func assertAccount(t *testing.T, store *account.Store, id uuid.UUID, want bool) {
t.Helper()
_, err := store.GetByID(context.Background(), id)
switch {
case err == nil:
if !want {
t.Errorf("account %s still exists, want reaped", id)
}
case errors.Is(err, account.ErrNotFound):
if want {
t.Errorf("account %s was reaped, want kept", id)
}
default:
t.Fatalf("get account %s: %v", id, err)
}
}
+302
View File
@@ -0,0 +1,302 @@
//go:build integration
package inttest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/session"
)
// --- merge test helpers ---
func setStats(t *testing.T, id uuid.UUID, w, l, d, mg, mw int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.account_stats (account_id, wins, losses, draws, max_game_points, max_word_points)
VALUES ($1,$2,$3,$4,$5,$6)
ON CONFLICT (account_id) DO UPDATE SET wins=$2, losses=$3, draws=$4, max_game_points=$5, max_word_points=$6`,
id, w, l, d, mg, mw); err != nil {
t.Fatalf("set stats: %v", err)
}
}
func setWallet(t *testing.T, id uuid.UUID, hints int, paid bool) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE backend.accounts SET hint_balance=$2, paid_account=$3 WHERE account_id=$1`, id, hints, paid); err != nil {
t.Fatalf("set wallet: %v", err)
}
}
func bindEmailIdentity(t *testing.T, acc uuid.UUID, email string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.identities (identity_id, account_id, kind, external_id, confirmed) VALUES ($1,$2,'email',$3,true)`,
uuid.New(), acc, email); err != nil {
t.Fatalf("bind email identity: %v", err)
}
}
func insertFriendship(t *testing.T, a, b uuid.UUID, status string) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`INSERT INTO backend.friendships (requester_id, addressee_id, status) VALUES ($1,$2,$3)`, a, b, status); err != nil {
t.Fatalf("insert friendship: %v", err)
}
}
func mergedInto(t *testing.T, id uuid.UUID) uuid.UUID {
t.Helper()
var into *uuid.UUID
if err := testDB.QueryRowContext(context.Background(),
`SELECT merged_into FROM backend.accounts WHERE account_id=$1`, id).Scan(&into); err != nil {
t.Fatalf("read merged_into: %v", err)
}
if into == nil {
return uuid.Nil
}
return *into
}
func seatCount(t *testing.T, gameID, accountID uuid.UUID) int {
t.Helper()
var n int
if err := testDB.QueryRowContext(context.Background(),
`SELECT count(*) FROM backend.game_players WHERE game_id=$1 AND account_id=$2`, gameID, accountID).Scan(&n); err != nil {
t.Fatalf("seat count: %v", err)
}
return n
}
func seatGame(t *testing.T, seats []uuid.UUID, timeout time.Duration) uuid.UUID {
t.Helper()
g, err := newGameService().Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: timeout, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("seat game: %v", err)
}
return g.ID
}
func newLinkService(mailer account.Mailer) *link.Service {
store := account.NewStore(testDB)
emails := account.NewEmailService(store, mailer)
sessions := session.NewService(session.NewStore(testDB), session.NewCache())
return link.NewService(emails, store, accountmerge.NewMerger(testDB), sessions)
}
// TestAccountMergeCore folds every account-scoped artifact of a secondary into a
// primary: stats summed, wallet summed, paid flag ORed, identity repointed, a
// non-shared game transferred, a friend carried over, and the secondary tombstoned.
func TestAccountMergeCore(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
friend := provisionAccount(t)
setStats(t, primary, 1, 0, 0, 100, 90)
setStats(t, secondary, 3, 1, 2, 400, 80)
setWallet(t, primary, 2, false)
setWallet(t, secondary, 5, true)
email := "merge-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, secondary, email)
insertFriendship(t, secondary, friend, "accepted")
gameID := seatGame(t, []uuid.UUID{secondary, friend}, 24*time.Hour)
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
w, l, d, mg, mw, found := readStats(t, primary)
if !found || w != 4 || l != 1 || d != 2 || mg != 400 || mw != 90 {
t.Errorf("primary stats = (%d,%d,%d,%d,%d found=%v), want (4,1,2,400,90 true)", w, l, d, mg, mw, found)
}
if _, _, _, _, _, found := readStats(t, secondary); found {
t.Error("secondary stats row should be deleted after merge")
}
acc, err := store.GetByID(ctx, primary)
if err != nil {
t.Fatalf("get primary: %v", err)
}
if acc.HintBalance != 7 {
t.Errorf("hint balance = %d, want 7", acc.HintBalance)
}
if !acc.PaidAccount {
t.Error("paid_account should be true (ORed from secondary)")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != primary {
t.Errorf("email owner = %s ok=%v, want primary %s", owner, ok, primary)
}
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 0 {
t.Error("non-shared game seat should transfer to primary")
}
if friends, _ := newSocialService().ListFriends(ctx, primary); len(friends) != 1 || friends[0] != friend {
t.Errorf("primary friends = %v, want [%s]", friends, friend)
}
if mergedInto(t, secondary) != primary {
t.Errorf("secondary.merged_into = %s, want primary %s", mergedInto(t, secondary), primary)
}
}
// TestAccountMergeActiveGameConflict refuses a merge when the two share an active
// game (one player cannot be merged against themselves).
func TestAccountMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
if err := merger.Merge(ctx, primary, secondary); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("merge = %v, want ErrActiveGameConflict", err)
}
if mergedInto(t, secondary) != uuid.Nil {
t.Error("a refused merge must not tombstone the secondary")
}
}
// TestAccountMergeFinishedSharedGameKept allows a merge when the shared game is
// finished and leaves the secondary's seat in place (the tombstone keeps the
// no-cascade foreign key valid).
func TestAccountMergeFinishedSharedGameKept(t *testing.T) {
ctx := context.Background()
merger := accountmerge.NewMerger(testDB)
primary := provisionAccount(t)
secondary := provisionAccount(t)
gameID := seatGame(t, []uuid.UUID{primary, secondary}, 24*time.Hour)
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, gameID); err != nil {
t.Fatalf("finish game: %v", err)
}
if err := merger.Merge(ctx, primary, secondary); err != nil {
t.Fatalf("merge: %v", err)
}
if seatCount(t, gameID, primary) != 1 || seatCount(t, gameID, secondary) != 1 {
t.Error("a shared finished game must keep both seats (secondary as tombstone)")
}
if mergedInto(t, secondary) != primary {
t.Error("secondary should be tombstoned")
}
}
// TestAccountLinkFreeEmail binds a free email and promotes a guest to durable.
func TestAccountLinkFreeEmail(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
guest := provisionGuest(t)
email := "fresh-" + uuid.NewString() + "@example.com"
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
res, err := links.ConfirmEmail(ctx, guest, email, sixDigit.FindString(mailer.lastBody))
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !res.Linked || res.MergeRequired {
t.Fatalf("confirm = %+v, want linked", res)
}
acc, _ := store.GetByID(ctx, guest)
if acc.IsGuest {
t.Error("guest flag should clear once an identity is linked")
}
if owner, ok, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); !ok || owner != guest {
t.Errorf("email owner = %s, want the promoted guest %s", owner, guest)
}
}
// TestAccountLinkEmailMergeIntoCaller merges the email's owner into the current
// (durable) account: the caller stays primary and keeps its session.
func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
caller := provisionAccount(t)
other := provisionAccount(t)
email := "owned-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, other, email)
if err := links.RequestEmail(ctx, caller, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
confirm, err := links.ConfirmEmail(ctx, caller, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
if !confirm.MergeRequired || confirm.SecondaryID != other {
t.Fatalf("confirm = %+v, want merge_required to other %s", confirm, other)
}
merge, err := links.MergeEmail(ctx, caller, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
}
if merge.PrimaryID != caller || merge.SwitchedToken != "" {
t.Fatalf("merge = %+v, want primary caller and no session switch", merge)
}
if mergedInto(t, other) != caller {
t.Error("other should be tombstoned into caller")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != caller {
t.Errorf("email owner = %s, want caller", owner)
}
}
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
// that owns the email: the durable account wins and a fresh session is minted.
func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "durable-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
t.Fatalf("confirm: %v", err)
}
merge, err := links.MergeEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
}
if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
}
if merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
t.Error("the guest should be tombstoned into the durable account")
}
if owner, _, _ := store.AccountIDByIdentity(ctx, account.KindEmail, email); owner != durable {
t.Errorf("email owner = %s, want durable", owner)
}
}
+163
View File
@@ -0,0 +1,163 @@
// Package link orchestrates account linking & merge (Stage 11, ARCHITECTURE.md §4).
// It sits above the account, accountmerge and session layers: it verifies the
// caller's control of an identity (an email confirm-code or a gateway-validated
// platform identity), binds a free identity to the current account, and — when the
// identity already has its own account — merges the two. The current account is the
// merge primary, except when the initiator is a guest and the other account is
// durable, in which case the durable account wins and a fresh session is minted for
// it (the client switches to it).
package link
import (
"context"
"github.com/google/uuid"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/session"
)
// Service drives the link/merge flow.
type Service struct {
emails *account.EmailService
accounts *account.Store
merger *accountmerge.Merger
sessions *session.Service
}
// NewService constructs a Service over its collaborators.
func NewService(emails *account.EmailService, accounts *account.Store, merger *accountmerge.Merger, sessions *session.Service) *Service {
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
}
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
// required (the caller renders an irreversible-merge confirmation from it).
type ConfirmResult struct {
Linked bool
MergeRequired bool
SecondaryID uuid.UUID
}
// MergeResult reports a completed merge. PrimaryID is the surviving account.
// SwitchedToken is a fresh session token for the primary when the active account
// changed (a guest initiator whose durable counterpart won); empty otherwise, in
// which case the caller keeps its current session.
type MergeResult struct {
PrimaryID uuid.UUID
SwitchedToken string
}
// RequestEmail mails a confirm-code for email to the caller (always sent).
func (s *Service) RequestEmail(ctx context.Context, accountID uuid.UUID, email string) error {
return s.emails.RequestLinkCode(ctx, accountID, email)
}
// ConfirmEmail verifies the code and either binds the free address to the caller
// (Linked) or reports that the address belongs to another account (MergeRequired).
func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email, code string) (ConfirmResult, error) {
owner, linked, err := s.emails.ConfirmLink(ctx, accountID, email, code)
if err != nil {
return ConfirmResult{}, err
}
if linked {
if err := s.accounts.ClearGuest(ctx, accountID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeEmail re-verifies the code and merges the address's account into the
// caller's (subject to the guest-primary rule).
func (s *Service) MergeEmail(ctx context.Context, callerID uuid.UUID, email, code string) (MergeResult, error) {
owner, linked, err := s.emails.ConfirmLink(ctx, callerID, email, code)
if err != nil {
return MergeResult{}, err
}
if linked {
// Raced to free/self between confirm and merge: it is now simply linked.
if err := s.accounts.ClearGuest(ctx, callerID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// ConfirmTelegram attaches a gateway-validated Telegram identity to the caller
// (Linked) or reports that it belongs to another account (MergeRequired).
func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (ConfirmResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
if err != nil {
return ConfirmResult{}, err
}
if !ok {
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Linked: true}, nil
}
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
// MergeTelegram merges the account owning a gateway-validated Telegram identity
// into the caller's (subject to the guest-primary rule).
func (s *Service) MergeTelegram(ctx context.Context, callerID uuid.UUID, externalID string) (MergeResult, error) {
owner, ok, err := s.accounts.AccountIDByIdentity(ctx, account.KindTelegram, externalID)
if err != nil {
return MergeResult{}, err
}
if !ok {
if err := s.attachTelegram(ctx, callerID, externalID); err != nil {
return MergeResult{}, err
}
return MergeResult{PrimaryID: callerID}, nil
}
if owner == callerID {
return MergeResult{PrimaryID: callerID}, nil
}
return s.merge(ctx, callerID, owner)
}
// attachTelegram links the identity to the caller and promotes a guest.
func (s *Service) attachTelegram(ctx context.Context, callerID uuid.UUID, externalID string) error {
if err := s.accounts.AttachIdentity(ctx, callerID, account.KindTelegram, externalID, true); err != nil {
return err
}
return s.accounts.ClearGuest(ctx, callerID)
}
// merge decides the primary (the caller, unless it is a guest and the other is
// durable), runs the data merge, retires the secondary's sessions and mints a new
// session when the active account switches.
func (s *Service) merge(ctx context.Context, callerID, otherID uuid.UUID) (MergeResult, error) {
caller, err := s.accounts.GetByID(ctx, callerID)
if err != nil {
return MergeResult{}, err
}
primary, secondary := callerID, otherID
if caller.IsGuest {
primary, secondary = otherID, callerID
}
if err := s.merger.Merge(ctx, primary, secondary); err != nil {
return MergeResult{}, err
}
if err := s.sessions.RevokeAllForAccount(ctx, secondary); err != nil {
return MergeResult{}, err
}
res := MergeResult{PrimaryID: primary}
if primary != callerID {
token, _, err := s.sessions.Create(ctx, primary)
if err != nil {
return MergeResult{}, err
}
res.SwitchedToken = token
}
return res, nil
}
@@ -25,4 +25,9 @@ type Accounts struct {
AwayEnd time.Time
HintBalance int32
IsGuest bool
NotificationsInAppOnly bool
PaidAccount bool
MergedInto *uuid.UUID
MergedAt *time.Time
ServiceLanguage *string
}
@@ -23,4 +23,8 @@ type Complaints struct {
Note string
Status string
CreatedAt time.Time
Disposition string
ResolutionNote string
ResolvedAt *time.Time
AppliedInVersion string
}
@@ -29,6 +29,11 @@ type accountsTable struct {
AwayEnd postgres.ColumnTime
HintBalance postgres.ColumnInteger
IsGuest postgres.ColumnBool
NotificationsInAppOnly postgres.ColumnBool
PaidAccount postgres.ColumnBool
MergedInto postgres.ColumnString
MergedAt postgres.ColumnTimestampz
ServiceLanguage postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -82,9 +87,14 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
AwayEndColumn = postgres.TimeColumn("away_end")
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
IsGuestColumn = postgres.BoolColumn("is_guest")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only")
PaidAccountColumn = postgres.BoolColumn("paid_account")
MergedIntoColumn = postgres.StringColumn("merged_into")
MergedAtColumn = postgres.TimestampzColumn("merged_at")
ServiceLanguageColumn = postgres.StringColumn("service_language")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn}
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
)
return accountsTable{
@@ -103,6 +113,11 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
AwayEnd: AwayEndColumn,
HintBalance: HintBalanceColumn,
IsGuest: IsGuestColumn,
NotificationsInAppOnly: NotificationsInAppOnlyColumn,
PaidAccount: PaidAccountColumn,
MergedInto: MergedIntoColumn,
MergedAt: MergedAtColumn,
ServiceLanguage: ServiceLanguageColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -27,6 +27,10 @@ type complaintsTable struct {
Note postgres.ColumnString
Status postgres.ColumnString
CreatedAt postgres.ColumnTimestampz
Disposition postgres.ColumnString
ResolutionNote postgres.ColumnString
ResolvedAt postgres.ColumnTimestampz
AppliedInVersion postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -78,9 +82,13 @@ func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable
NoteColumn = postgres.StringColumn("note")
StatusColumn = postgres.StringColumn("status")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn}
DispositionColumn = postgres.StringColumn("disposition")
ResolutionNoteColumn = postgres.StringColumn("resolution_note")
ResolvedAtColumn = postgres.TimestampzColumn("resolved_at")
AppliedInVersionColumn = postgres.StringColumn("applied_in_version")
allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn, DispositionColumn, ResolutionNoteColumn, ResolvedAtColumn, AppliedInVersionColumn}
mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn, DispositionColumn, ResolutionNoteColumn, ResolvedAtColumn, AppliedInVersionColumn}
defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn, DispositionColumn, ResolutionNoteColumn, AppliedInVersionColumn}
)
return complaintsTable{
@@ -97,6 +105,10 @@ func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable
Note: NoteColumn,
Status: StatusColumn,
CreatedAt: CreatedAtColumn,
Disposition: DispositionColumn,
ResolutionNote: ResolutionNoteColumn,
ResolvedAt: ResolvedAtColumn,
AppliedInVersion: AppliedInVersionColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -0,0 +1,17 @@
-- +goose Up
-- Stage 9 Telegram integration: a per-account toggle that confines notifications
-- to the in-app live stream. When notifications_in_app_only is true (the default),
-- the platform side-service (Telegram) sends no out-of-app push; turning it off
-- opts into out-of-app push, which the gateway delivers only while the account has
-- no live in-app stream, so the in-app and platform channels never duplicate. Adds
-- a column, so the generated jet code is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN notifications_in_app_only boolean NOT NULL DEFAULT true;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
DROP COLUMN notifications_in_app_only;
@@ -0,0 +1,30 @@
-- +goose Up
-- Stage 10 admin & dictionary ops: the word-check complaint resolution lifecycle.
-- Stage 3 created complaints with a free-form status (only ever 'open'); the admin
-- review queue (this stage) resolves them with a disposition that also feeds the
-- offline dictionary-rebuild pipeline: an accepted complaint records whether the
-- word should be added or removed, and is marked applied once a rebuilt dictionary
-- version is hot-reloaded. No operator identity is recorded (the gateway gates the
-- console behind Basic-Auth; the backend keeps no admin principal). Adds columns, so
-- the generated jet code is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE complaints
ADD COLUMN disposition text NOT NULL DEFAULT '',
ADD COLUMN resolution_note text NOT NULL DEFAULT '',
ADD COLUMN resolved_at timestamptz,
ADD COLUMN applied_in_version text NOT NULL DEFAULT '',
ADD CONSTRAINT complaints_status_chk CHECK (status IN ('open', 'resolved')),
ADD CONSTRAINT complaints_disposition_chk
CHECK (disposition IN ('', 'reject', 'accept_add', 'accept_remove'));
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE complaints
DROP CONSTRAINT complaints_disposition_chk,
DROP CONSTRAINT complaints_status_chk,
DROP COLUMN applied_in_version,
DROP COLUMN resolved_at,
DROP COLUMN resolution_note,
DROP COLUMN disposition;
@@ -0,0 +1,24 @@
-- +goose Up
-- Stage 11 account linking & merge: retire a secondary account into a primary one.
-- merged_into/merged_at turn the secondary into an audit tombstone (its identities
-- are repointed and its non-shared rows transferred to the primary, but the row is
-- kept so the no-cascade game_players/chat/complaints foreign keys of any shared
-- finished game stay valid). merged_into self-references accounts and is SET NULL on
-- delete so a future guest reaper (PLAN.md TODO-3) can still remove a primary.
-- paid_account is a forward-looking lifetime one-time-payment marker (no purchase
-- flow yet); the merge ORs it so a paid status is never lost. Adds columns, so the
-- generated jet code is regenerated (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN paid_account boolean NOT NULL DEFAULT false,
ADD COLUMN merged_into uuid REFERENCES accounts (account_id) ON DELETE SET NULL,
ADD COLUMN merged_at timestamptz;
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
DROP COLUMN merged_at,
DROP COLUMN merged_into,
DROP COLUMN paid_account;
@@ -0,0 +1,21 @@
-- +goose Up
-- Stage 15 dual Telegram bots: service_language records the language tag of the bot
-- a Telegram user last authenticated through (their last ValidateInitData). It is
-- updated on every Telegram login — new and existing accounts — and routes the
-- user's out-of-app push back through the right bot. It is distinct from
-- preferred_language (the interface language) and from a game's variant language.
-- Nullable: an account that has never signed in through a tagged bot (legacy,
-- email-only or guest) has no value, and push routing falls back to
-- preferred_language. Adds a column, so the generated jet code is regenerated
-- (cmd/jetgen).
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN service_language text
CHECK (service_language IN ('en', 'ru'));
-- +goose Down
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
DROP COLUMN service_language;
+2 -1
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"net"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.uber.org/zap"
"google.golang.org/grpc"
@@ -78,7 +79,7 @@ func NewServer(addr string, hub *notify.Hub, log *zap.Logger) *Server {
if log == nil {
log = zap.NewNop()
}
gs := grpc.NewServer()
gs := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
pushv1.RegisterPushServer(gs, NewService(hub, log))
return &Server{grpc: gs, addr: addr, log: log}
}
+36 -6
View File
@@ -45,6 +45,7 @@ type profileResponse struct {
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
}
// tileDTO is one placed (or to-place) tile.
@@ -100,13 +101,24 @@ type moveResultDTO struct {
Game gameDTO `json:"game"`
}
// stateDTO is a player's view of a game.
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
// tile value), embedded in the state view for display only when the client requests it
// (Stage 13).
type alphabetEntryDTO struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
// stateDTO is a player's view of a game. Rack carries wire alphabet indices (Stage 13; a
// blank is engine.BlankIndex). Alphabet is present only when the request asked for it.
type stateDTO struct {
Game gameDTO `json:"game"`
Seat int `json:"seat"`
Rack []string `json:"rack"`
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
}
// matchDTO reports whether the caller has been paired into a game.
@@ -158,6 +170,7 @@ func profileResponseFor(acc account.Account) profileResponse {
BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest,
NotificationsInAppOnly: acc.NotificationsInAppOnly,
}
}
@@ -215,15 +228,32 @@ func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
}
// stateDTOFrom projects a player's state view into its DTO.
func stateDTOFrom(v game.StateView) stateDTO {
return stateDTO{
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's
// display table, which the client caches per variant and renders the rack with.
func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
rack, err := engine.EncodeRack(v.Game.Variant, v.Rack)
if err != nil {
return stateDTO{}, err
}
dto := stateDTO{
Game: gameDTOFromGame(v.Game),
Seat: v.Seat,
Rack: v.Rack,
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
if err != nil {
return stateDTO{}, err
}
dto.Alphabet = make([]alphabetEntryDTO, len(tab))
for i, e := range tab {
dto.Alphabet[i] = alphabetEntryDTO{Index: int(e.Index), Letter: e.Letter, Value: e.Value}
}
}
return dto, nil
}
// matchDTOFrom projects an enqueue/poll result into its DTO.
+17 -5
View File
@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/accountmerge"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/lobby"
@@ -31,6 +32,10 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession)
// Out-of-app push routing for the platform side-service (Stage 9): the
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
// before delivering an out-of-app notification.
in.POST("/push-target", s.handlePushTarget)
}
u := s.user
if s.accounts != nil {
@@ -38,9 +43,15 @@ func (s *Server) registerRoutes() {
u.PUT("/profile", s.handleUpdateProfile)
u.GET("/stats", s.handleStats)
}
if s.emails != nil {
u.POST("/email/request", s.handleEmailBindRequest)
u.POST("/email/confirm", s.handleEmailBindConfirm)
if s.links != nil {
// Account linking & merge (Stage 11). The request step always mails a code;
// a required merge is revealed only after the code is verified, and the
// irreversible merge is an explicit second step.
u.POST("/link/email/request", s.handleLinkEmailRequest)
u.POST("/link/email/confirm", s.handleLinkEmailConfirm)
u.POST("/link/email/merge", s.handleLinkEmailMerge)
u.POST("/link/telegram", s.handleLinkTelegram)
u.POST("/link/telegram/merge", s.handleLinkTelegramMerge)
}
if s.games != nil {
u.GET("/games", s.handleListGames)
@@ -83,7 +94,6 @@ func (s *Server) registerRoutes() {
u.POST("/blocks", s.handleBlock)
u.DELETE("/blocks/:id", s.handleUnblock)
}
s.admin.GET("/ping", s.handleAdminPing)
}
// userID returns the authenticated account id stored by RequireUserID. The user
@@ -176,8 +186,10 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "hint_unavailable"
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
return http.StatusUnprocessableEntity, "illegal_play"
case errors.Is(err, account.ErrEmailTaken):
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
return http.StatusConflict, "email_taken"
case errors.Is(err, accountmerge.ErrActiveGameConflict):
return http.StatusConflict, "merge_active_game_conflict"
case errors.Is(err, account.ErrInvalidEmail):
return http.StatusBadRequest, "invalid_email"
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
+2 -51
View File
@@ -25,6 +25,7 @@ type updateProfileRequest struct {
AwayEnd string `json:"away_end"`
BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
}
// statsDTO is a durable account's lifetime statistics (the derived games-played and
@@ -37,17 +38,6 @@ type statsDTO struct {
MaxWordPoints int `json:"max_word_points"`
}
// emailBindRequestBody starts binding an email to the caller's account.
type emailBindRequestBody struct {
Email string `json:"email"`
}
// emailBindConfirmBody completes binding an email with its confirm code.
type emailBindConfirmBody struct {
Email string `json:"email"`
Code string `json:"code"`
}
// parseAwayTime parses an "HH:MM" away-window bound.
func parseAwayTime(s string) (time.Time, bool) {
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
@@ -87,6 +77,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
AwayEnd: awayEnd,
BlockChat: req.BlockChat,
BlockFriendRequests: req.BlockFriendRequests,
NotificationsInAppOnly: req.NotificationsInAppOnly,
})
if err != nil {
s.abortErr(c, err)
@@ -115,43 +106,3 @@ func (s *Server) handleStats(c *gin.Context) {
MaxWordPoints: st.MaxWordPoints,
})
}
// handleEmailBindRequest issues a confirm code to bind an email to the caller.
func (s *Server) handleEmailBindRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req emailBindRequestBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if err := s.emails.RequestCode(c.Request.Context(), uid, req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleEmailBindConfirm verifies the code and binds the email, returning the
// updated profile.
func (s *Server) handleEmailBindConfirm(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req emailBindConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
acc, err := s.emails.ConfirmCode(c.Request.Context(), uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, profileResponseFor(acc))
}
-16
View File
@@ -1,16 +0,0 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
)
// The /api/v1/admin/* endpoints are reached through the gateway's Basic-Auth
// reverse proxy (docs/ARCHITECTURE.md §12). The backend trusts the gateway to
// have authenticated the operator; the admin surface itself (complaint review,
// dictionary versions) lands in Stage 10. handleAdminPing is the proxy target that
// proves the path end to end until then.
func (s *Server) handleAdminPing(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
@@ -0,0 +1,463 @@
package server
import (
"bytes"
"fmt"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
)
// adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50
// registerConsole mounts the server-rendered admin console under /_gm. The gateway
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard
// on the state-changing POSTs (docs/ARCHITECTURE.md §12). The console reads the
// account, game and dictionary surfaces, so it mounts only when those are wired.
func (s *Server) registerConsole(router *gin.Engine) {
if s.accounts == nil || s.games == nil || s.registry == nil {
return
}
s.console = adminconsole.MustNewRenderer()
assets, err := adminconsole.Assets()
if err != nil {
panic(err)
}
gm := router.Group("/_gm")
gm.Use(requireSameOrigin())
gm.StaticFS("/assets", http.FS(assets))
gm.GET("/", s.consoleDashboard)
gm.GET("/users", s.consoleUsers)
gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
gm.GET("/dictionary", s.consoleDictionary)
gm.POST("/dictionary/reload", s.consoleReloadDictionary)
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
gm.GET("/broadcast", s.consoleBroadcast)
gm.POST("/broadcast", s.consolePostBroadcast)
}
// consoleDashboard renders the landing page: the top-line counts and the resident
// dictionary versions.
func (s *Server) consoleDashboard(c *gin.Context) {
ctx := c.Request.Context()
view := adminconsole.DashboardView{Variants: s.variantVersions()}
view.Accounts, _ = s.accounts.CountAccounts(ctx)
view.Games, _ = s.games.CountGames(ctx, "")
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
if changes, err := s.games.DictionaryChanges(ctx); err == nil {
view.PendingChanges = len(changes)
}
s.renderConsole(c, "dashboard", "dashboard", "Dashboard", view)
}
// consoleUsers renders the paginated account list.
func (s *Server) consoleUsers(c *gin.Context) {
ctx := c.Request.Context()
page := consolePage(c)
total, _ := s.accounts.CountAccounts(ctx)
accs, err := s.accounts.ListAccounts(ctx, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.UsersView{Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, a := range accs {
kind := "registered"
if a.IsGuest {
kind = "guest"
}
view.Items = append(view.Items, adminconsole.UserRow{
ID: a.ID.String(), DisplayName: a.DisplayName, Kind: kind,
Language: a.PreferredLanguage, Guest: a.IsGuest, CreatedAt: fmtTime(a.CreatedAt),
})
}
s.renderConsole(c, "users", "users", "Users", view)
}
// consoleUserDetail renders one account with its stats, identities and games.
func (s *Server) consoleUserDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
acc, err := s.accounts.GetByID(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.UserDetailView{
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, CreatedAt: fmtTime(acc.CreatedAt),
HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
}
if acc.MergedInto != uuid.Nil {
view.MergedInto = acc.MergedInto.String()
}
if view.HasStats {
if st, err := s.accounts.GetStats(ctx, id); err == nil {
view.Stats = adminconsole.StatsRow{Wins: st.Wins, Losses: st.Losses, Draws: st.Draws, MaxGamePoints: st.MaxGamePoints, MaxWordPoints: st.MaxWordPoints}
}
}
if ids, err := s.accounts.Identities(ctx, id); err == nil {
for _, idn := range ids {
view.Identities = append(view.Identities, adminconsole.IdentityRow{Kind: idn.Kind, ExternalID: idn.ExternalID, Confirmed: idn.Confirmed, CreatedAt: fmtTime(idn.CreatedAt)})
}
}
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg
}
if games, err := s.games.ListForAccount(ctx, id); err == nil {
for _, g := range games {
view.Games = append(view.Games, gameRow(g))
}
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// consoleUserMessage sends an operator Telegram message to one user.
func (s *Server) consoleUserMessage(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
text := trimForm(c, "text")
language := trimForm(c, "language")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back)
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", back)
default:
ext, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram)
if err != nil {
s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back)
return
}
delivered, err := s.connector.SendToUser(ctx, ext, text, language)
if err != nil {
s.consoleError(c, err)
return
}
body := "message delivered"
if !delivered {
body = "not delivered (the user may not have started that bot)"
}
s.renderConsoleMessage(c, "Sent", body, back)
}
}
// consoleGames renders the paginated games list, optionally filtered by status.
func (s *Server) consoleGames(c *gin.Context) {
ctx := c.Request.Context()
status := normalizeGameStatus(c.Query("status"))
page := consolePage(c)
total, _ := s.games.CountGames(ctx, status)
games, err := s.games.ListGames(ctx, status, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.GamesView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, g := range games {
view.Items = append(view.Items, gameRow(g))
}
s.renderConsole(c, "games", "games", "Games", view)
}
// consoleGameDetail renders one game with its seats (display names resolved).
func (s *Server) consoleGameDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/games")
if !ok {
return
}
g, err := s.games.GameByID(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.GameDetailView{
ID: g.ID.String(), Variant: g.Variant.String(), DictVersion: g.DictVersion,
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt),
}
for _, seat := range g.Seats {
row := adminconsole.SeatRow{Seat: seat.Seat, AccountID: seat.AccountID.String(), Score: seat.Score, HintsUsed: seat.HintsUsed, Winner: seat.IsWinner}
if acc, err := s.accounts.GetByID(ctx, seat.AccountID); err == nil {
row.DisplayName = acc.DisplayName
}
view.Seats = append(view.Seats, row)
}
s.renderConsole(c, "game_detail", "games", "Game", view)
}
// consoleComplaints renders the paginated complaint review queue.
func (s *Server) consoleComplaints(c *gin.Context) {
ctx := c.Request.Context()
status := normalizeComplaintStatus(c.Query("status"))
page := consolePage(c)
total, _ := s.games.CountComplaints(ctx, status)
rows, err := s.games.ListComplaints(ctx, status, adminPageSize, (page-1)*adminPageSize)
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.ComplaintsView{Status: status, Pager: adminconsole.NewPager(page, adminPageSize, total)}
for _, cp := range rows {
view.Items = append(view.Items, adminconsole.ComplaintRow{
ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), WasValid: cp.WasValid,
Status: cp.Status, Disposition: cp.Disposition, CreatedAt: fmtTime(cp.CreatedAt),
})
}
s.renderConsole(c, "complaints", "complaints", "Complaints", view)
}
// consoleComplaintDetail renders one complaint with its resolution form.
func (s *Server) consoleComplaintDetail(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/complaints")
if !ok {
return
}
cp, err := s.games.GetComplaint(ctx, id)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsole(c, "complaint_detail", "complaints", "Complaint", adminconsole.ComplaintDetailView{
ID: cp.ID.String(), Word: cp.Word, Variant: cp.Variant.String(), DictVersion: cp.DictVersion,
WasValid: cp.WasValid, Note: cp.Note, Status: cp.Status, Disposition: cp.Disposition,
ResolutionNote: cp.ResolutionNote, CreatedAt: fmtTime(cp.CreatedAt), ResolvedAt: fmtTimePtr(cp.ResolvedAt),
GameID: cp.GameID.String(), Resolved: cp.Status == game.StatusComplaintResolved,
})
}
// consoleResolveComplaint resolves a complaint with the chosen disposition.
func (s *Server) consoleResolveComplaint(c *gin.Context) {
ctx := c.Request.Context()
id, ok := s.consoleUUID(c, "/_gm/complaints")
if !ok {
return
}
disposition := c.PostForm("disposition")
if _, err := s.games.ResolveComplaint(ctx, id, disposition, trimForm(c, "note")); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Resolved", "complaint resolved as "+disposition, "/_gm/complaints/"+id.String())
}
// consoleDictionary renders the resident versions and the pending wordlist changes.
func (s *Server) consoleDictionary(c *gin.Context) {
view := adminconsole.DictionaryView{Variants: s.variantVersions()}
if changes, err := s.games.DictionaryChanges(c.Request.Context()); err == nil {
for _, ch := range changes {
action := "remove"
if ch.Add {
action = "add"
}
view.Changes = append(view.Changes, adminconsole.DictChangeRow{Variant: ch.Variant.String(), Word: ch.Word, Action: action, ResolvedAt: fmtTime(ch.ResolvedAt)})
}
}
s.renderConsole(c, "dictionary", "dictionary", "Dictionary", view)
}
// consoleReloadDictionary hot-loads a dictionary version from its subdirectory.
func (s *Server) consoleReloadDictionary(c *gin.Context) {
version := trimForm(c, "version")
if version == "" {
s.renderConsoleMessage(c, "Reload failed", "a version is required", "/_gm/dictionary")
return
}
dir := filepath.Join(s.dictDir, version)
loaded, err := s.registry.LoadAvailable(dir, version)
if err != nil {
s.consoleError(c, err)
return
}
if len(loaded) == 0 {
s.renderConsoleMessage(c, "Nothing loaded", "no dictionary files found in "+dir, "/_gm/dictionary")
return
}
names := make([]string, len(loaded))
for i, v := range loaded {
names[i] = v.String()
}
s.renderConsoleMessage(c, "Reloaded", fmt.Sprintf("loaded %v as version %q", names, version), "/_gm/dictionary")
}
// consoleApplyChanges marks a variant's pending accepted changes applied in a
// reloaded version.
func (s *Server) consoleApplyChanges(c *gin.Context) {
variant, err := engine.ParseVariant(c.PostForm("variant"))
if err != nil {
s.renderConsoleMessage(c, "Apply failed", "unknown variant", "/_gm/dictionary")
return
}
version := trimForm(c, "version")
if version == "" {
s.renderConsoleMessage(c, "Apply failed", "a version is required", "/_gm/dictionary")
return
}
n, err := s.games.MarkChangesApplied(c.Request.Context(), variant, version)
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Applied", fmt.Sprintf("marked %d change(s) applied in %q", n, version), "/_gm/dictionary")
}
// consoleBroadcast renders the operator-broadcast form.
func (s *Server) consoleBroadcast(c *gin.Context) {
s.renderConsole(c, "broadcast", "broadcast", "Broadcast", adminconsole.BroadcastView{ConnectorEnabled: s.connector != nil})
}
// consolePostBroadcast posts an operator message to the connector's game channel.
func (s *Server) consolePostBroadcast(c *gin.Context) {
text := trimForm(c, "text")
language := trimForm(c, "language")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast")
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast")
default:
delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text, language)
if err != nil {
s.consoleError(c, err)
return
}
body := "posted to the game channel"
if !delivered {
body = "not delivered (that bot has no game channel configured)"
}
s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
}
}
// variantVersions builds the per-variant resident-version summary from the registry.
func (s *Server) variantVersions() []adminconsole.VariantVersions {
out := make([]adminconsole.VariantVersions, 0, len(engine.Variants()))
for _, v := range engine.Variants() {
vv := adminconsole.VariantVersions{Variant: v.String(), Versions: s.registry.Versions(v)}
if latest, _, err := s.registry.Latest(v); err == nil {
vv.Latest = latest
}
out = append(out, vv)
}
return out
}
// renderConsole renders a console page into a buffer, then writes it; a render
// failure is logged and reported as 500 without emitting a partial document.
func (s *Server) renderConsole(c *gin.Context, page, nav, title string, data any) {
var buf bytes.Buffer
if err := s.console.Render(&buf, page, adminconsole.PageData{Title: title, ActiveNav: nav, Data: data}); err != nil {
s.log.Error("admin console render", zap.String("page", page), zap.Error(err))
c.String(http.StatusInternalServerError, "render error")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
// renderConsoleMessage renders the post-action result page with a back link.
func (s *Server) renderConsoleMessage(c *gin.Context, heading, body, back string) {
s.renderConsole(c, "message", "", heading, adminconsole.MessageView{Heading: heading, Body: body, Back: back})
}
// consoleError logs an unexpected console error and renders it on the message page.
// The console is operator-only (gateway Basic-Auth), so the message is shown as-is.
func (s *Server) consoleError(c *gin.Context, err error) {
s.log.Error("admin console", zap.String("path", c.FullPath()), zap.Error(err))
s.renderConsole(c, "message", "", "Error", adminconsole.MessageView{Heading: "Error", Body: err.Error(), Back: "/_gm/"})
}
// consoleUUID parses the :id path parameter, rendering an invalid-id message and
// returning false when it is not a UUID.
func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
s.renderConsoleMessage(c, "Invalid id", "the id in the URL is not valid", back)
return uuid.UUID{}, false
}
return id, true
}
// gameRow projects a game summary into its console row.
func gameRow(g game.Game) adminconsole.GameRow {
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt)}
}
// trimForm returns the trimmed value of a posted form field.
func trimForm(c *gin.Context, name string) string {
return strings.TrimSpace(c.PostForm(name))
}
// consolePage parses the 1-based ?page query parameter, defaulting to 1.
func consolePage(c *gin.Context) int {
if n, err := strconv.Atoi(c.Query("page")); err == nil && n > 1 {
return n
}
return 1
}
// normalizeGameStatus keeps only a recognised game status filter, else "" (all).
func normalizeGameStatus(s string) string {
switch s {
case game.StatusActive, game.StatusFinished:
return s
}
return ""
}
// normalizeComplaintStatus keeps only a recognised complaint status filter, else
// "" (all).
func normalizeComplaintStatus(s string) string {
switch s {
case game.StatusComplaintOpen, game.StatusComplaintResolved:
return s
}
return ""
}
// fmtTime formats a timestamp for display, or "" when zero.
func fmtTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format("2006-01-02 15:04")
}
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string {
if t == nil {
return ""
}
return fmtTime(*t)
}
+74 -4
View File
@@ -1,9 +1,11 @@
package server
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account"
)
@@ -14,28 +16,96 @@ import (
// account and mint the opaque session. The backend trusts the gateway on this
// segment (docs/ARCHITECTURE.md §12).
// telegramAuthRequest carries the platform user id the gateway extracted from a
// validated initData payload.
// telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only).
// ServiceLanguage is the validating bot's language tag (en/ru); it is recorded on
// every login (the bot the user last came through) and routes their out-of-app push.
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
ServiceLanguage string `json:"service_language"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
// identity and mints a session for it.
// identity and mints a session for it, seeding a new account's display name and
// language from the supplied Telegram fields and recording the validating bot's
// service language (updated every login) so out-of-app push routes to that bot.
func (s *Server) handleTelegramAuth(c *gin.Context) {
var req telegramAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "external_id is required")
return
}
acc, err := s.accounts.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID)
acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
if err != nil {
s.abortErr(c, err)
return
}
if err := s.accounts.SetServiceLanguage(c.Request.Context(), acc.ID, req.ServiceLanguage); err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
type pushTargetRequest struct {
UserID string `json:"user_id"`
}
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
// the recipient's Telegram external_id (empty when they have no Telegram
// identity, e.g. a guest or email-only account), the language that both selects the
// delivering bot and renders the message (the account's service language, the bot
// it last signed in through, falling back to its preferred language), and whether
// they confined notifications to the in-app stream.
type pushTargetResponse struct {
ExternalID string `json:"external_id"`
Language string `json:"language"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
}
// handlePushTarget resolves a user id to the data the gateway needs to deliver an
// out-of-app Telegram notification — the gateway-only internal counterpart of the
// in-app push stream. A user with no Telegram identity yields an empty external_id,
// which the gateway treats as "no out-of-app channel".
func (s *Server) handlePushTarget(c *gin.Context) {
var req pushTargetRequest
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == "" {
abortBadRequest(c, "user_id is required")
return
}
uid, err := uuid.Parse(req.UserID)
if err != nil {
abortBadRequest(c, "user_id must be a uuid")
return
}
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return
}
ext, err := s.accounts.IdentityExternalID(c.Request.Context(), uid, account.KindTelegram)
if err != nil && !errors.Is(err, account.ErrNotFound) {
s.abortErr(c, err)
return
}
// Route by the bot the user last signed in through; fall back to the interface
// language for an account that has never come through a tagged bot.
language := acc.ServiceLanguage
if language == "" {
language = acc.PreferredLanguage
}
c.JSON(http.StatusOK, pushTargetResponse{
ExternalID: ext,
Language: language,
NotificationsInAppOnly: acc.NotificationsInAppOnly,
})
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
func (s *Server) handleGuestAuth(c *gin.Context) {
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
+57 -8
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -51,9 +52,10 @@ type chatListDTO struct {
Messages []chatDTO `json:"messages"`
}
// exchangeRequest swaps the given rack tiles back into the bag.
// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet
// indices (Stage 13); a blank is engine.BlankIndex.
type exchangeRequest struct {
Tiles []string `json:"tiles"`
Tiles []int `json:"tiles"`
}
// complaintRequest disputes a word-check result.
@@ -125,7 +127,17 @@ func (s *Server) handleExchange(c *gin.Context) {
abortBadRequest(c, "invalid request body")
return
}
res, err := s.games.Exchange(c.Request.Context(), gameID, uid, req.Tiles)
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
tiles, err := engine.DecodeTiles(variant, req.Tiles)
if err != nil {
s.abortErr(c, err)
return
}
res, err := s.games.Exchange(c.Request.Context(), gameID, uid, tiles)
if err != nil {
s.abortErr(c, err)
return
@@ -180,9 +192,15 @@ func (s *Server) handleEvaluate(c *gin.Context) {
abortBadRequest(c, "dir must be H or V")
return
}
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
for _, t := range req.Tiles {
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
tiles, err := tilesFromRequest(variant, req)
if err != nil {
s.abortErr(c, err)
return
}
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles)
if err != nil {
@@ -192,13 +210,29 @@ func (s *Server) handleEvaluate(c *gin.Context) {
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words})
}
// handleCheckWord looks a word up in the game's pinned dictionary.
// handleCheckWord looks a word up in the game's pinned dictionary. The word arrives as
// repeated ?idx= alphabet indices (Stage 13); the backend decodes them to the concrete
// word for the lookup and echoes that concrete word back for the client's result cache.
func (s *Server) handleCheckWord(c *gin.Context) {
_, gameID, ok := s.userGame(c)
if !ok {
return
}
word := c.Query("word")
idx, err := queryIndexes(c, "idx")
if err != nil {
abortBadRequest(c, "invalid word")
return
}
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
word, err := engine.DecodeWord(variant, idx)
if err != nil {
s.abortErr(c, err)
return
}
legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
if err != nil {
s.abortErr(c, err)
@@ -207,6 +241,21 @@ func (s *Server) handleCheckWord(c *gin.Context) {
c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
}
// queryIndexes parses repeated integer query parameters (e.g. ?idx=2&idx=0) into a slice.
// It carries a word-check query as alphabet indices on a GET (Stage 13).
func queryIndexes(c *gin.Context, key string) ([]int, error) {
raw := c.QueryArray(key)
out := make([]int, 0, len(raw))
for _, s := range raw {
n, err := strconv.Atoi(s)
if err != nil {
return nil, err
}
out = append(out, n)
}
return out, nil
}
// handleComplaint files a word-check complaint into the admin review queue.
func (s *Server) handleComplaint(c *gin.Context) {
uid, gameID, ok := s.userGame(c)
+200
View File
@@ -0,0 +1,200 @@
package server
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/link"
)
// The /api/v1/user/link handlers drive account linking & merge (Stage 11). The
// request step always mails a code (no pre-send "taken" signal, so a probe cannot
// enumerate registered emails); confirm reveals a required merge only after the
// code is verified; merge performs the irreversible consolidation behind an
// explicit step. A merge into a guest initiator's durable counterpart switches the
// active session — the new token rides back in the result for the client to adopt.
// linkEmailRequestBody starts a link/merge by mailing a code to email.
type linkEmailRequestBody struct {
Email string `json:"email"`
}
// linkEmailConfirmBody carries the email and its confirm code.
type linkEmailConfirmBody struct {
Email string `json:"email"`
Code string `json:"code"`
}
// linkTelegramBody carries a gateway-validated Telegram identity.
type linkTelegramBody struct {
ExternalID string `json:"external_id"`
}
// linkResultResponse is the unified result of a confirm or merge step. Status is
// "linked" (bound to the caller), "merge_required" (the identity belongs to another
// account — the secondary_* fields summarise it for the irreversible confirmation),
// or "merged" (done; token is non-empty when the active account switched).
type linkResultResponse struct {
Status string `json:"status"`
SecondaryUserID string `json:"secondary_user_id,omitempty"`
SecondaryName string `json:"secondary_display_name,omitempty"`
SecondaryGames int `json:"secondary_games"`
SecondaryFriends int `json:"secondary_friends"`
Token string `json:"token,omitempty"`
Profile *profileResponse `json:"profile,omitempty"`
}
// handleLinkEmailRequest mails a confirm-code to email for a later link or merge.
func (s *Server) handleLinkEmailRequest(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailRequestBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
if err := s.links.RequestEmail(c.Request.Context(), uid, req.Email); err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, okResponse{OK: true})
}
// handleLinkEmailConfirm verifies the code and binds a free email or reports a
// required merge.
func (s *Server) handleLinkEmailConfirm(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
res, err := s.links.ConfirmEmail(c.Request.Context(), uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
}
// handleLinkEmailMerge re-verifies the code and performs the merge.
func (s *Server) handleLinkEmailMerge(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkEmailConfirmBody
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
res, err := s.links.MergeEmail(c.Request.Context(), uid, req.Email, req.Code)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// handleLinkTelegram attaches a gateway-validated Telegram identity to the caller
// or reports a required merge.
func (s *Server) handleLinkTelegram(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkTelegramBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.ConfirmTelegram(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.confirmResultResponse(c, uid, res))
}
// handleLinkTelegramMerge merges the account owning a gateway-validated Telegram
// identity into the caller's.
func (s *Server) handleLinkTelegramMerge(c *gin.Context) {
uid, ok := userID(c)
if !ok {
abortBadRequest(c, "missing identity")
return
}
var req linkTelegramBody
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "missing external_id")
return
}
res, err := s.links.MergeTelegram(c.Request.Context(), uid, req.ExternalID)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, s.mergeResultResponse(c, res))
}
// confirmResultResponse renders a confirm step: a merge preview (secondary summary)
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
ctx := c.Request.Context()
if res.MergeRequired {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
out.SecondaryName = acc.DisplayName
}
out.SecondaryGames, out.SecondaryFriends = s.secondaryCounts(ctx, res.SecondaryID)
return out
}
return linkResultResponse{Status: "linked", Profile: s.profileFor(ctx, activeID)}
}
// mergeResultResponse renders a completed merge: the surviving account's profile
// plus a switched-session token when the active account changed.
func (s *Server) mergeResultResponse(c *gin.Context, res link.MergeResult) linkResultResponse {
return linkResultResponse{
Status: "merged",
Token: res.SwitchedToken,
Profile: s.profileFor(c.Request.Context(), res.PrimaryID),
}
}
// profileFor loads an account's profile DTO, or nil when it cannot be read.
func (s *Server) profileFor(ctx context.Context, id uuid.UUID) *profileResponse {
acc, err := s.accounts.GetByID(ctx, id)
if err != nil {
return nil
}
p := profileResponseFor(acc)
return &p
}
// secondaryCounts summarises the to-be-retired account for the merge confirmation.
func (s *Server) secondaryCounts(ctx context.Context, id uuid.UUID) (games, friends int) {
if s.games != nil {
if gs, err := s.games.ListForAccount(ctx, id); err == nil {
games = len(gs)
}
}
if s.social != nil {
if fs, err := s.social.ListFriends(ctx, id); err == nil {
friends = len(fs)
}
}
return games, friends
}
-7
View File
@@ -43,13 +43,6 @@ func do(t *testing.T, s *Server, method, path, body string, headers map[string]s
return rec
}
func TestAdminPingOK(t *testing.T) {
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/admin/ping", "", nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"status":"ok"`) {
t.Fatalf("admin ping = %d %q", rec.Code, rec.Body.String())
}
}
func TestProfileRequiresUserID(t *testing.T) {
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
if rec.Code != http.StatusUnauthorized {
+32 -6
View File
@@ -26,17 +26,33 @@ func (s *Server) handleProfile(c *gin.Context) {
c.JSON(http.StatusOK, profileResponseFor(acc))
}
// submitPlayRequest places tiles in a direction on the player's turn.
// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter
// is a wire alphabet index (Stage 13); for a blank it is the designated letter's index.
type submitPlayRequest struct {
Dir string `json:"dir"`
Tiles []struct {
Row int `json:"row"`
Col int `json:"col"`
Letter string `json:"letter"`
Letter int `json:"letter"`
Blank bool `json:"blank"`
} `json:"tiles"`
}
// tilesFromRequest maps a play/evaluate request's index-addressed tiles to engine tile
// records for the game's variant (Stage 13: a placed blank carries its designated letter's
// index with Blank set). An out-of-range index surfaces as engine.ErrIllegalPlay (HTTP 400).
func tilesFromRequest(variant engine.Variant, req submitPlayRequest) ([]engine.TileRecord, error) {
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
for _, t := range req.Tiles {
letter, err := engine.LetterForIndex(variant, t.Letter)
if err != nil {
return nil, err
}
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: letter, Blank: t.Blank})
}
return tiles, nil
}
// handleSubmitPlay validates, scores and commits a placement.
func (s *Server) handleSubmitPlay(c *gin.Context) {
uid, ok := userID(c)
@@ -59,9 +75,15 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
abortBadRequest(c, "dir must be H or V")
return
}
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
for _, t := range req.Tiles {
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
if err != nil {
s.abortErr(c, err)
return
}
tiles, err := tilesFromRequest(variant, req)
if err != nil {
s.abortErr(c, err)
return
}
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles)
if err != nil {
@@ -88,7 +110,11 @@ func (s *Server) handleGameState(c *gin.Context) {
s.abortErr(c, err)
return
}
dto := stateDTOFrom(view)
dto, err := stateDTOFrom(view, c.Query("include_alphabet") == "true")
if err != nil {
s.abortErr(c, err)
return
}
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
c.JSON(http.StatusOK, dto)
}
+38
View File
@@ -3,6 +3,7 @@ package server
import (
"context"
"net/http"
"net/url"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -39,3 +40,40 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
return id, ok
}
// requireSameOrigin guards the admin console's state-changing requests: it rejects
// a non-safe request whose Origin (or, failing that, Referer) host does not match
// the request Host. The gateway authenticates the operator with Basic-Auth in front
// of /_gm; this same-origin check is the console's CSRF defence, stopping a
// cross-site form POST from riding the cached credential. Safe methods pass through.
func requireSameOrigin() gin.HandlerFunc {
return func(c *gin.Context) {
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
c.Next()
return
}
if !sameOrigin(c.Request) {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
}
}
// sameOrigin reports whether the request's Origin (or, failing that, Referer) host
// matches the request Host. A state-changing request carrying neither header is
// rejected.
func sameOrigin(r *http.Request) bool {
for _, h := range []string{r.Header.Get("Origin"), r.Header.Get("Referer")} {
if h == "" {
continue
}
u, err := url.Parse(h)
if err != nil {
return false
}
return u.Host == r.Host
}
return false
}
@@ -0,0 +1,51 @@
package server
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// TestSameOriginGuard checks the admin console's CSRF defence: safe methods pass,
// a state-changing request needs an Origin/Referer host matching the request Host.
func TestSameOriginGuard(t *testing.T) {
gin.SetMode(gin.TestMode)
e := gin.New()
g := e.Group("/_gm")
g.Use(requireSameOrigin())
g.POST("/act", func(c *gin.Context) { c.Status(http.StatusOK) })
g.GET("/page", func(c *gin.Context) { c.Status(http.StatusOK) })
cases := []struct {
name string
method string
path string
origin string
referer string
want int
}{
{"get is safe", http.MethodGet, "/_gm/page", "", "", http.StatusOK},
{"post without origin rejected", http.MethodPost, "/_gm/act", "", "", http.StatusForbidden},
{"post matching origin ok", http.MethodPost, "/_gm/act", "http://example.com", "", http.StatusOK},
{"post foreign origin rejected", http.MethodPost, "/_gm/act", "http://evil.test", "", http.StatusForbidden},
{"post matching referer ok", http.MethodPost, "/_gm/act", "", "http://example.com/_gm/x", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(tc.method, "http://example.com"+tc.path, nil)
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
if tc.referer != "" {
req.Header.Set("Referer", tc.referer)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
+26 -5
View File
@@ -18,7 +18,11 @@ import (
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
@@ -55,6 +59,18 @@ type Deps struct {
Matchmaker *lobby.Matchmaker
Invitations *lobby.InvitationService
Emails *account.EmailService
// Links drives account linking & merge (Stage 11): the /api/v1/user/link
// endpoints. A nil Links disables them.
Links *link.Service
// Registry holds the resident dictionaries; the admin console (Stage 10) reads
// its versions and hot-reloads new ones. DictDir is the dictionary directory a
// reload reads a version subdirectory from. A nil Registry disables the console.
Registry *engine.Registry
DictDir string
// Connector is the backend's Telegram connector client for operator broadcasts;
// nil when BACKEND_CONNECTOR_ADDR is unset (broadcasts show a "not configured"
// notice).
Connector *connector.Client
}
// Server owns the gin engine, the underlying HTTP server and the readiness
@@ -73,11 +89,15 @@ type Server struct {
matchmaker *lobby.Matchmaker
invitations *lobby.InvitationService
emails *account.EmailService
links *link.Service
registry *engine.Registry
dictDir string
connector *connector.Client
console *adminconsole.Renderer
public *gin.RouterGroup
user *gin.RouterGroup
internal *gin.RouterGroup
admin *gin.RouterGroup
}
// New returns a Server that will listen on addr. It installs the recovery and
@@ -109,11 +129,16 @@ func New(addr string, deps Deps) *Server {
matchmaker: deps.Matchmaker,
invitations: deps.Invitations,
emails: deps.Emails,
links: deps.Links,
registry: deps.Registry,
dictDir: deps.DictDir,
connector: deps.Connector,
http: &http.Server{Addr: addr, Handler: engine},
}
s.registerProbes(engine)
s.registerAPIGroups(engine)
s.registerRoutes()
s.registerConsole(engine)
return s
}
@@ -153,7 +178,6 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
s.user = v1.Group("/user")
s.user.Use(RequireUserID())
s.internal = v1.Group("/internal")
s.admin = v1.Group("/admin")
}
// PublicGroup returns the unauthenticated public route group.
@@ -165,9 +189,6 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
// InternalGroup returns the gateway-facing internal route group.
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
// AdminGroup returns the admin route group (authenticated at the gateway).
func (s *Server) AdminGroup() *gin.RouterGroup { return s.admin }
// Social returns the social domain service for the handlers added in Stage 6.
func (s *Server) Social() *social.Service { return s.social }
+18
View File
@@ -4,6 +4,8 @@ import (
"context"
"sync"
"sync/atomic"
"github.com/google/uuid"
)
// Cache is the in-memory write-through projection of the active rows in
@@ -93,3 +95,19 @@ func (c *Cache) Remove(tokenHash string) {
defer c.mu.Unlock()
delete(c.byHash, tokenHash)
}
// RemoveByAccount evicts every cached session belonging to accountID. The
// account-merge flow uses it to drop a retired secondary account's sessions
// (Stage 11); a linear scan is adequate at the cache's size.
func (c *Cache) RemoveByAccount(accountID uuid.UUID) {
if c == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
for hash, s := range c.byHash {
if s.AccountID == accountID {
delete(c.byHash, hash)
}
}
}
+11
View File
@@ -71,3 +71,14 @@ func (svc *Service) Revoke(ctx context.Context, token string) error {
svc.cache.Remove(hash)
return nil
}
// RevokeAllForAccount revokes every active session of accountID and evicts them
// from the cache. The account-merge flow calls it to retire a secondary account
// (Stage 11). It is idempotent.
func (svc *Service) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID) error {
if _, err := svc.store.RevokeAllForAccount(ctx, accountID, time.Now().UTC()); err != nil {
return err
}
svc.cache.RemoveByAccount(accountID)
return nil
}
+28
View File
@@ -110,6 +110,34 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time
return modelToSession(row), true, nil
}
// RevokeAllForAccount transitions every active session of accountID to revoked
// and returns the post-update rows (so the caller can evict them from the cache).
// It backs the account-merge flow, which retires a secondary account's sessions
// (Stage 11). No matching rows is not an error.
func (s *Store) RevokeAllForAccount(ctx context.Context, accountID uuid.UUID, at time.Time) ([]Session, error) {
stmt := table.Sessions.
UPDATE(table.Sessions.Status, table.Sessions.RevokedAt).
SET(postgres.String(StatusRevoked), postgres.TimestampzT(at)).
WHERE(
table.Sessions.AccountID.EQ(postgres.UUID(accountID)).
AND(table.Sessions.Status.EQ(postgres.String(StatusActive))),
).
RETURNING(table.Sessions.AllColumns)
var rows []model.Sessions
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("session: revoke all for account %s: %w", accountID, err)
}
out := make([]Session, 0, len(rows))
for _, row := range rows {
out = append(out, modelToSession(row))
}
return out, nil
}
// ListActive loads every active session. Cache.Warm calls this at boot.
func (s *Store) ListActive(ctx context.Context) ([]Session, error) {
stmt := postgres.SELECT(table.Sessions.AllColumns).
+2
View File
@@ -77,6 +77,7 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindMessage)
svc.emitChat(seats, senderID, msg)
return msg, nil
}
@@ -110,6 +111,7 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
if err != nil {
return Message{}, err
}
svc.metrics.recordChat(ctx, kindNudge)
if toMove >= 0 && toMove < len(seats) {
svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID))
}
+49
View File
@@ -0,0 +1,49 @@
package social
import (
"context"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
)
// meterName scopes the social domain's OpenTelemetry instruments.
const meterName = "scrabble/backend/social"
// socialMetrics holds the social domain's operational instruments. It defaults to
// no-ops (see defaultSocialMetrics); SetMetrics installs the real meter during
// startup wiring.
type socialMetrics struct {
messages metric.Int64Counter
}
// defaultSocialMetrics returns instruments backed by a no-op meter.
func defaultSocialMetrics() *socialMetrics {
return newSocialMetrics(noop.NewMeterProvider().Meter(meterName))
}
// newSocialMetrics builds the instruments on meter, falling back to a no-op
// counter on the (rare) construction error.
func newSocialMetrics(meter metric.Meter) *socialMetrics {
c, err := meter.Int64Counter("chat_messages_total",
metric.WithDescription("Per-game chat entries posted, labelled by kind (message/nudge)."))
if err != nil {
c, _ = noop.NewMeterProvider().Meter(meterName).Int64Counter("chat_messages_total")
}
return &socialMetrics{messages: c}
}
// SetMetrics installs the meter the social domain records to. It must be called
// during startup wiring; the default is a no-op meter.
func (svc *Service) SetMetrics(meter metric.Meter) {
if meter == nil {
return
}
svc.metrics = newSocialMetrics(meter)
}
// recordChat counts one posted chat entry of the given kind (message or nudge).
func (m *socialMetrics) recordChat(ctx context.Context, kind string) {
m.messages.Add(ctx, 1, metric.WithAttributes(attribute.String("kind", kind)))
}
+48
View File
@@ -0,0 +1,48 @@
package social
import (
"context"
"testing"
"go.opentelemetry.io/otel/attribute"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
)
// TestSocialMetrics records chat and nudge entries through a manual reader and
// asserts chat_messages_total splits by the "kind" attribute.
func TestSocialMetrics(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newSocialMetrics(meter)
m.recordChat(ctx, kindMessage)
m.recordChat(ctx, kindMessage)
m.recordChat(ctx, kindNudge)
var rm metricdata.ResourceMetrics
if err := reader.Collect(ctx, &rm); err != nil {
t.Fatalf("collect: %v", err)
}
got := map[string]int64{}
for _, sm := range rm.ScopeMetrics {
for _, md := range sm.Metrics {
if md.Name != "chat_messages_total" {
continue
}
sum, ok := md.Data.(metricdata.Sum[int64])
if !ok {
t.Fatalf("chat_messages_total is not an int64 sum")
}
for _, dp := range sum.DataPoints {
v, _ := dp.Attributes.Value(attribute.Key("kind"))
got[v.AsString()] += dp.Value
}
}
}
if got[kindMessage] != 2 || got[kindNudge] != 1 {
t.Errorf("chat_messages_total = %v, want message:2 nudge:1", got)
}
}
+2
View File
@@ -76,6 +76,7 @@ type Service struct {
accounts *account.Store
games GameReader
pub notify.Publisher
metrics *socialMetrics
now func() time.Time
}
@@ -87,6 +88,7 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic
accounts: accounts,
games: games,
pub: notify.Nop{},
metrics: defaultSocialMetrics(),
now: func() time.Time { return time.Now().UTC() },
}
}
+32 -168
View File
@@ -1,158 +1,58 @@
// Package telemetry owns the OpenTelemetry runtime for the backend process.
//
// New constructs the configured tracer and meter providers, registers them as
// the OpenTelemetry globals, and exposes Shutdown for orderly exit. The MVP
// supports the `none` and `stdout` exporters; OTLP export and dashboards arrive
// in a later stage. The per-request timing middleware lives in middleware.go and
// uses the registered global tracer, so requests are timed and logged even when
// the exporter is `none`.
// Package telemetry owns the backend's OpenTelemetry wiring. The provider
// bootstrap (exporter selection, propagators, shutdown, Go runtime metrics) is
// shared across the Scrabble services in scrabble/pkg/telemetry; this package is a
// thin backend-flavoured facade over it (the "scrabble-backend" default service
// name) plus the backend-specific gin request-timing middleware (middleware.go),
// which uses the registered global tracer so requests are timed and logged even
// when the exporter is "none".
package telemetry
import (
"context"
"errors"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
"go.uber.org/zap"
pkgtel "scrabble/pkg/telemetry"
)
// Exporter selectors supported by the backend.
// Exporter selectors, re-exported from scrabble/pkg/telemetry so the backend's
// config and tests need not import the shared package directly.
const (
ExporterNone = "none"
ExporterStdout = "stdout"
ExporterNone = pkgtel.ExporterNone
ExporterStdout = pkgtel.ExporterStdout
ExporterOTLP = pkgtel.ExporterOTLP
)
// DefaultServiceName labels traces and metrics when BACKEND_SERVICE_NAME is
// unset.
// DefaultServiceName labels traces and metrics when BACKEND_SERVICE_NAME is unset.
const DefaultServiceName = "scrabble-backend"
// Config selects the telemetry providers' service name and exporters.
type Config struct {
// ServiceName is reported as the OpenTelemetry service.name resource.
ServiceName string
// TracesExporter is one of ExporterNone or ExporterStdout.
TracesExporter string
// MetricsExporter is one of ExporterNone or ExporterStdout.
MetricsExporter string
}
// Config selects the telemetry providers' service name and exporters. It aliases
// the shared configuration type.
type Config = pkgtel.Config
// DefaultConfig returns the MVP telemetry configuration: named service, no
// exporters (so no collector is required locally or in CI).
// Runtime owns the shared OpenTelemetry providers. It aliases the shared runtime
// type, so callers keep using telemetry.Runtime.
type Runtime = pkgtel.Runtime
// DefaultConfig returns the backend's telemetry configuration: the
// "scrabble-backend" service name and both exporters off (so no collector is
// required locally or in CI).
func DefaultConfig() Config {
return Config{
ServiceName: DefaultServiceName,
TracesExporter: ExporterNone,
MetricsExporter: ExporterNone,
}
}
// Validate reports whether the configuration selects supported exporters.
func (c Config) Validate() error {
if c.ServiceName == "" {
return errors.New("telemetry: ServiceName must not be empty")
}
if err := validateExporter("traces", c.TracesExporter); err != nil {
return err
}
return validateExporter("metrics", c.MetricsExporter)
}
func validateExporter(kind, value string) error {
switch value {
case ExporterNone, ExporterStdout:
return nil
default:
return fmt.Errorf("telemetry: unsupported %s exporter %q", kind, value)
}
}
// Runtime owns the shared OpenTelemetry providers.
type Runtime struct {
tracerProvider *sdktrace.TracerProvider
meterProvider *sdkmetric.MeterProvider
return pkgtel.DefaultConfig(DefaultServiceName)
}
// New constructs the telemetry runtime, registers the global providers and the
// W3C trace-context/baggage propagators, and returns the Runtime. Callers must
// invoke Runtime.Shutdown during process exit.
// W3C propagators, and returns the Runtime. Callers must invoke Runtime.Shutdown
// during process exit.
func New(ctx context.Context, cfg Config) (*Runtime, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
res, err := resource.New(ctx, resource.WithAttributes(
attribute.String("service.name", cfg.ServiceName),
))
if err != nil {
return nil, fmt.Errorf("telemetry: build resource: %w", err)
}
tracerProvider, err := newTracerProvider(cfg, res)
if err != nil {
return nil, fmt.Errorf("telemetry: build tracer provider: %w", err)
}
meterProvider, err := newMeterProvider(cfg, res)
if err != nil {
_ = tracerProvider.Shutdown(ctx)
return nil, fmt.Errorf("telemetry: build meter provider: %w", err)
}
otel.SetTracerProvider(tracerProvider)
otel.SetMeterProvider(meterProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return &Runtime{tracerProvider: tracerProvider, meterProvider: meterProvider}, nil
}
// TracerProvider returns the runtime tracer provider, or the global one when r
// is not initialised.
func (r *Runtime) TracerProvider() trace.TracerProvider {
if r == nil || r.tracerProvider == nil {
return otel.GetTracerProvider()
}
return r.tracerProvider
}
// MeterProvider returns the runtime meter provider, or the global one when r is
// not initialised.
func (r *Runtime) MeterProvider() metric.MeterProvider {
if r == nil || r.meterProvider == nil {
return otel.GetMeterProvider()
}
return r.meterProvider
}
// Shutdown flushes both providers within ctx.
func (r *Runtime) Shutdown(ctx context.Context) error {
if r == nil {
return nil
}
var err error
if r.meterProvider != nil {
err = errors.Join(err, r.meterProvider.Shutdown(ctx))
}
if r.tracerProvider != nil {
err = errors.Join(err, r.tracerProvider.Shutdown(ctx))
}
return err
return pkgtel.New(ctx, cfg)
}
// TraceFieldsFromContext returns zap fields identifying the active span, or nil
// when ctx carries no valid span context. Collocated here so callers do not
// import the OpenTelemetry API directly.
// when ctx carries no valid span context. Collocated here so callers (the
// request-timing middleware and the access log) do not import the OpenTelemetry
// API directly.
func TraceFieldsFromContext(ctx context.Context) []zap.Field {
if ctx == nil {
return nil
@@ -166,39 +66,3 @@ func TraceFieldsFromContext(ctx context.Context) []zap.Field {
zap.String("otel_span_id", sc.SpanID().String()),
}
}
func newTracerProvider(cfg Config, res *resource.Resource) (*sdktrace.TracerProvider, error) {
switch cfg.TracesExporter {
case ExporterNone:
return sdktrace.NewTracerProvider(sdktrace.WithResource(res)), nil
case ExporterStdout:
exporter, err := stdouttrace.New()
if err != nil {
return nil, fmt.Errorf("stdout trace exporter: %w", err)
}
return sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
), nil
default:
return nil, fmt.Errorf("unsupported traces exporter %q", cfg.TracesExporter)
}
}
func newMeterProvider(cfg Config, res *resource.Resource) (*sdkmetric.MeterProvider, error) {
switch cfg.MetricsExporter {
case ExporterNone:
return sdkmetric.NewMeterProvider(sdkmetric.WithResource(res)), nil
case ExporterStdout:
exporter, err := stdoutmetric.New()
if err != nil {
return nil, fmt.Errorf("stdout metric exporter: %w", err)
}
return sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter)),
), nil
default:
return nil, fmt.Errorf("unsupported metrics exporter %q", cfg.MetricsExporter)
}
}
+9 -3
View File
@@ -12,9 +12,15 @@ func TestConfigValidate(t *testing.T) {
}
otlp := DefaultConfig()
otlp.TracesExporter = "otlp"
if err := otlp.Validate(); err == nil {
t.Error("otlp exporter must be rejected in the MVP set")
otlp.TracesExporter = ExporterOTLP
if err := otlp.Validate(); err != nil {
t.Errorf("otlp exporter must be accepted: %v", err)
}
bad := DefaultConfig()
bad.MetricsExporter = "prometheus"
if err := bad.Validate(); err == nil {
t.Error("unsupported exporter must be rejected")
}
noName := DefaultConfig()
+156 -49
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:
@@ -43,9 +44,17 @@ Three executables plus per-platform side-services:
a server-driven channel later, §10), and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot
first): deep-link invites and platform-native push notifications. They talk
to `backend` over an internal API.
- **`platform/telegram`** — the Telegram side-service (the "connector", module
`scrabble/platform/telegram`). It is the only component holding the bot tokens — **one
bot per service language** (`en`/`ru`), each its own token + game channel, the same
Telegram user id spanning both (§3). It
runs a Bot API long-poll loop per bot (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` (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
Telegram through a VPN sidecar.
```mermaid
flowchart LR
@@ -55,7 +64,9 @@ flowchart LR
Gateway -- in-app stream --> Client
Backend -- pgx --> Postgres[(Postgres)]
Backend -. embeds .- Solver[[scrabble-solver library]]
Telegram[Telegram bot side-service] -- internal API --> Backend
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector]
Backend -. operator broadcasts (gRPC) .-> Telegram
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram))
```
The MVP runs `gateway` and `backend` as single-instance processes inside a
@@ -78,6 +89,18 @@ dropped). Horizontal scaling is explicit future work.
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes.
- **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not
concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the
`Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet
(a blank is the sentinel index **255**). The client is **alphabet-agnostic**: on a
per-variant cache miss it sets `StateRequest.include_alphabet`, and the backend embeds the
variant's `(index, letter, value)` table (`engine.AlphabetTable`, derived from the solver
ruleset — no dictionary) for display; the client caches it by variant and renders the rack
and the blank chooser from it. The backend maps index↔letter at its REST edge, so the
gateway forwards indices **verbatim** (it holds no alphabet table) and the engine's
letter-based domain API — shared with the robot — is unchanged. The table is pinned by the
solver version, so it cannot drift from the running backend. The **move journal, history
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
`X-User-ID` for authenticated requests; `backend` never re-derives identity
from the body.
@@ -92,17 +115,35 @@ Platform-native, deliberately simple: **no Ed25519 client keys, no per-request
signing, no anti-replay crypto** (these were considered and dropped — players
arrive from a platform rather than completing a mandatory registration).
- The gateway validates the originating credential **once**the platform's
signed launch data (e.g. Telegram `initData` HMAC), an email-code login, or a
guest bootstrap — then mints a **thin opaque server session token**
(`session_id`).
- The gateway validates the originating credential **once**Telegram `initData`
(delegated to the connector's `ValidateInitData` RPC, which holds the bot token —
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4).
- **Service language & variant gating (Stage 15).** The connector hosts **one bot per
service language** (`en`/`ru`), each its own token + game channel; the same Telegram
user id spans both. `ValidateInitData` tries each token in turn and returns the
validating bot's **service language** and its **supported-languages set**. The set
rides the **`Session`** (FlatBuffers, session-scoped, not persisted): the UI offers
only the variants those languages support on New Game (`en` → English; `ru` → Russian
+ Эрудит). **Starting** a new game is the only gated action — opening and playing
existing games of any language is unrestricted, and the backend does not enforce the
gate (it is a product affordance, not a trust boundary). The service language is
**persisted** per account (`accounts.service_language`, updated on every Telegram
login — last-login-wins) and routes the user's out-of-app push back through the right
bot (§10); it is distinct from `preferred_language` (the interface language) and from
a game's variant language. Non-Telegram logins (web / email / guest) carry the
gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default).
- The client holds `session_id` in memory for the app session (browser/OS
storage is optional and may be unavailable; losing it means re-login).
- The gateway caches `session → user_id` and injects `X-User-ID`. Session
records live in `backend`, which stores only a **SHA-256 hash** of the opaque
token (never the plaintext), keeps a warmed in-memory cache for fast
resolution, and treats sessions as **revoke-only** — they have no TTL and live
until explicitly revoked (`status``revoked`).
until explicitly revoked (`status``revoked`). A revoke can target one token or,
on an account merge (§4), **every** session of the retired account
(`RevokeAllForAccount`, which also evicts them from the warm cache).
- **Guest** = ephemeral web session (no platform, no email). A guest is backed by
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
row is a technical necessity (the `sessions` and `game_players` foreign keys
@@ -123,17 +164,33 @@ arrive from a platform rather than completing a mandatory registration).
authenticated account: a 6-digit code (stored only as a SHA-256 hash, 15-minute
TTL, ≤ 5 attempts) is sent through a `Mailer` seam (an SMTP relay, or a
development log mailer when none is configured) and, once verified, attaches a
confirmed email identity. An email already confirmed by **another** account is
refused — adopting it would be a merge, which Stage 11 owns. Accounts and
identities use application-generated **UUIDv7** primary keys.
- **Linking** is initiated from an authenticated profile: choose a platform →
complete that platform's web-auth confirm → attach the identity to the
current account.
- **Merge**: if the identity being linked already has its own account with
history, the two accounts are **merged into the current one (A is primary)**:
statistics are summed, games and friends are transferred, duplicates are
de-duplicated, the secondary account is retired. High blast-radius; an
isolated, well-tested stage.
confirmed email identity. Accounts and identities use application-generated
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
payment; no purchase flow yet) is carried on the account and ORed on a merge.
- **Linking** (Stage 11) is initiated from an authenticated profile and proves
control of the identity before attaching it: **email** through the confirm-code
flow, **Telegram** through the web **Login Widget** (validated by the connector,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
request step **always** sends/accepts the proof (no pre-send "already taken"
signal, so a probe cannot enumerate registered addresses); a required **merge**
is revealed **only after** the proof is verified and is performed behind an
explicit, irreversible confirmation. A free identity is simply attached (and a
guest is promoted to durable, clearing `is_guest`).
- **Merge** retires the account that owns the linked identity into the **current**
account, in a single transaction (`internal/accountmerge`): statistics summed
(max points kept), the hint wallet summed, `paid_account` ORed, identities
repointed, games / chat / complaints transferred, friends and blocks
de-duplicated (friendships keep the strongest status accepted>pending>declined),
pending invitations/codes dropped, and the secondary kept as an **audit
tombstone** (`accounts.merged_into`/`merged_at`) so a shared **finished** game's
no-cascade foreign keys stay valid — its seat there is left untouched. A merge is
**refused** only when the two share an **active** game. The current account is the
primary, **except** when the initiator is a **guest** and the linked identity
already has a **durable** owner: then the durable account wins, the guest's active
games move into it, the guest is retired, and a **fresh session** is minted for the
durable account (the client switches to it). The secondary's sessions are revoked
(§3). High blast-radius; an isolated, well-tested stage.
## 5. Game engine integration (`scrabble-solver`)
@@ -156,11 +213,16 @@ 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. (The solver is published as a versioned module and the
dictionaries ship as a separate versioned **release artifact** from the
`scrabble-dictionary` repo — TODO-1/TODO-2, Stage 14; the runtime contract above is
unchanged.)
- 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**
@@ -221,8 +283,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
@@ -318,7 +383,10 @@ requires (there is no DM surface; chat is per-game).
keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's
migration `00005` added the `is_guest` flag for ephemeral guest rows),
migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's
migration `00007` added the `notifications_in_app_only` out-of-app push toggle;
Stage 11's migration `00009` added the `paid_account` service flag and the
merge-tombstone columns `merged_into`/`merged_at`),
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
Stage 5's migration `00004` admits the `robot` kind),
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
@@ -368,6 +436,11 @@ does not cover. **GCG export is offered only on a finished game** (`game.ErrGame
otherwise, Stage 8), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available, else downloads it.
The Stage 13 alphabet-on-the-wire change does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
game still replays with the variant's `rules.Alphabet` alone, independent of any dictionary.
## 10. Notifications
Two channels: the **in-app live stream** (delivered from Stage 6) and
@@ -387,9 +460,20 @@ the backend and forwarded verbatim. A client that is not currently streaming fal
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
badge** (incoming friend requests + open invitations), the client polls on lobby
open and on focus as well as re-polling on the `notify` event — covering a push
missed while the app was hidden. Out-of-app platform push (your-turn, nudge) is
wired in Stage 9; session-revocation events and cursor-based stream resume are
deferred (single-instance MVP).
missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
`external_id`, the **service language** — the bot they last signed in through, falling
back to the interface language — and the `notifications_in_app_only` flag) and asks the
**Telegram connector** to deliver a localized message with a Mini App deep-link
button — only when the recipient has a Telegram identity and has not confined
notifications to the app, so the two channels never duplicate. The connector routes by
that language to the matching bot and renders the message in it. The out-of-app set is
your-turn, nudge, match-found and the invitation / friend-request notify sub-kinds;
the connector renders the message and skips the rest. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP).
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
It is a client-side **mock** rotation today; a server-driven source (operational notices,
@@ -398,14 +482,27 @@ promotions) is future work and would deliver short markdown messages (text + lin
## 11. Observability
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
meter providers are wired (Stage 1), env-gated by
`BACKEND_OTEL_{TRACES,METRICS}_EXPORTER` with a default of `none` (so no
collector is required locally or in CI); `stdout` is available for debugging
and the Postgres pool is instrumented with otelsql. OTLP export, a Prometheus
pull endpoint, and dashboards arrive with the first real workload.
meter providers are wired in **all three services** (backend, gateway, the
Telegram connector) through a shared `pkg/telemetry` bootstrap, env-gated per
service by `{BACKEND,GATEWAY,TELEGRAM}_OTEL_{TRACES,METRICS}_EXPORTER` with a
default of `none` (so no collector is required locally or in CI). `stdout` is
available for debugging; **`otlp`** (gRPC, endpoint from the standard
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
and the gateway↔connector calls. The OTLP collector and Grafana dashboards are
stood up with the deploy (Stage 15).
- Per-request server-side timing via gin middleware from day one (the access log
carries method, route, status, latency and the active trace id). A
client-measured RTT piggybacked on the next request is a later enhancement.
- Domain/operational metrics (Stage 12), recorded through the meter and invisible
until an exporter is configured: histograms `game_replay_duration` (journal
rebuild on a cache miss) and `game_move_validate_duration`; counters
`games_started_total`, `games_abandoned_total` (a turn-timeout seat drop),
`chat_messages_total` (`kind` = message/nudge) and `robot_games_finished_total`;
an observable gauge `game_cache_active`; the gateway `edge_request_duration`
(the UI-perceived roundtrip, by `message_type`/`result`); and Go runtime/heap
metrics. Game-scoped metrics carry a `variant` attribute
(english/russian_scrabble/erudit).
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
database answers a bounded ping and the session cache is warmed).
- The backend serves a **second listener** — a gRPC server
@@ -417,11 +514,12 @@ promotions) is future work and would deliver short markdown messages (text + lin
| Concern | Enforced by |
| --- | --- |
| Public rate limiting / anti-abuse | gateway |
| Platform credential validation, session minting | gateway |
| Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector |
| 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 |
| backend ↔ gateway trust | the network (only gateway may reach backend) |
| 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
network segment defeats backend authentication. Mitigated by network isolation;
@@ -438,10 +536,16 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
## 13. Deployment (informational)
Single public origin, path-routed: the UI, the gateway public surface and the
admin surface share one host that terminates TLS. MVP runs one `gateway`, one
`backend`, one Postgres. Docker/compose environments are introduced when there
is something to deploy.
Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini
App under `/telegram/`** (the gateway serves the static UI build, wired in Stage 15;
outside Telegram 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
(`platform/telegram/deploy`, mirroring `../15-puzzle`); the gateway's static UI serving
and the full multi-service deploy land in Stage 15.
## 14. CI & branches
@@ -453,9 +557,12 @@ is something to deploy.
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
serial). Further workflows (ui-test, deploy) are added with the components they
cover.
- Since Stage 2 both Go workflows clone the public `scrabble-solver` sibling
(master HEAD, no credentials) into `../scrabble-solver` before building, so the
`go.work` `replace` resolves; the engine tests read the committed DAWGs from
that checkout via `BACKEND_DICT_DIR`.
- The engine consumes `scrabble-solver` as a **published, versioned module**
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
(no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release
artifact** from the `scrabble-dictionary` repo; the workflows download
`scrabble-dawg-<DICT_VERSION>.tar.gz` and point the engine tests at it via
`BACKEND_DICT_DIR` (TODO-1/TODO-2 discharged in Stage 14).
- After any push, the run is watched to green before a stage is declared done
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
+53 -26
View File
@@ -22,29 +22,48 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
### Identity & sessions *(Stage 1 / 6)*
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
A player arrives from a platform (Telegram first), via email login, or as an
ephemeral guest. The gateway validates the credential once and mints a thin
session token; the backend resolves it to an internal `user_id`. Guests are
session-only with restricted features (auto-match only; no friends, stats or
history). While the app is open the client keeps a live stream and receives
in-app updates in real time — the opponent's move, your turn, chat, nudges and a
found match; out-of-app push (your turn, nudge) is delivered by the platform
later (Stage 9).
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
App** launch authenticates from the platform's signed `initData`, themes the UI to
the Telegram colours, and — on first contact — seeds the new account's interface
language from the Telegram client. The sign-in service also declares the **game
languages** it offers (a set of en/ru, at least one), which gate the New Game variant
choice in the lobby. Telegram runs a separate bot per language (an English bot and a
Russian bot, the same player spanning both); the bot a player signed in through both
sets their offered languages and is the bot their out-of-app notifications come from. Guests are session-only with restricted features
(auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move,
your turn, chat, nudges and a found match. When the app is **closed**, the chosen
out-of-app events (your turn, nudge, a found match, an invitation or friend request)
arrive as a **Telegram notification** instead — unless the player keeps notifications
in the app only (a profile setting, **on by default**).
### Accounts, linking & merge *(Stage 1 / 10)*
First platform contact auto-provisions a durable account. From the profile a
player links additional platform identities or an email via a confirm flow;
linking an identity that already has history merges it into the current
account (stats summed, games/friends transferred).
### Accounts, linking & merge *(Stage 1 / 11)*
First platform contact auto-provisions a durable account. From the profile a player
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status
of an identity is never revealed before the code/sign-in is verified. If the linked
identity already belongs to another account, the player is shown an explicit,
**irreversible** confirmation and the two accounts are merged into the one they are
using (statistics summed, games and friends transferred, duplicates removed) — except
when a guest links an identity that already has a durable account, where the durable
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
### Lobby & matchmaking *(Stage 4)*
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a
per-variant pool and is paired with the next waiting human; after 10 s with no
human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
formed by inviting players from the friend list (deep-link invites arrive with the
platform integration): the inviter chooses the settings and the game starts once
every invitee has accepted — any decline cancels it, and an unanswered invitation
### Lobby & matchmaking *(Stage 4 / 15)*
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
limited to the languages the player's sign-in service supports (English → English;
Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is
unrestricted). This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language. Auto-match
(always 2 players) joins a per-variant pool and is paired with the next waiting human;
after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code,
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
expires after seven days.
### Playing a game *(Stage 3)*
@@ -91,11 +110,9 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo
### Profile & settings *(Stage 4 / 8)*
Edit the display name (letters joined by single space / "." / "_" separators, up to
32 characters), the timezone (chosen as a UTC offset), the daily away window (on a
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles, and bind
an email by confirm-code: the backend emails a short code that,
once entered, attaches the email to the account (an email already confirmed by
another account cannot be taken — that is a merge, a later stage). Linked platform
accounts and merge arrive in Stage 11.
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles. Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge" (Stage 11).
### History & statistics *(Stage 3 / 8)*
Finished games are archived in a dictionary-independent form and exportable to
@@ -106,5 +123,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.
+49 -23
View File
@@ -23,29 +23,49 @@ top-1 подсказку, безлимитную проверку слова с
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
### Личность и сессии *(Stage 1 / 6)*
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Гость —
только сессия, с урезанными функциями (только авто-подбор; без друзей,
статистики и истории). Пока приложение открыто, клиент держит живой стрим и
получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и
найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит
позже (Stage 9).
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он
предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в
лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки и
является тем ботом, от которого приходят его внеприложенческие уведомления. Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят
вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления
только в приложении (настройка профиля, **включена по умолчанию**).
### Аккаунты, привязка и слияние *(Stage 1 / 10)*
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
привязывает другие платформенные личности или email через confirm-поток;
привязка личности, у которой уже есть история, сливает её в текущий аккаунт
(статистика суммируется, игры/друзья переносятся).
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
### Лобби и подбор *(Stage 4)*
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
### Лобби и подбор *(Stage 4 / 15)*
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → English;
русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашения по deep-link
появятся с платформенной интеграцией): инициатор выбирает настройки, и партия
стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)*
@@ -94,11 +114,8 @@ push доставляется через платформу.
Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
переключателей блокировок, а также привязка email по confirm-коду: backend шлёт на
почту короткий код, и после ввода email
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
слияние появятся в Stage 11.
переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов
вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
### История и статистика *(Stage 3 / 8)*
Завершённые партии архивируются в независимом от словаря виде и экспортируются
@@ -109,5 +126,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; личность оператора не отслеживается.
+21
View File
@@ -82,6 +82,27 @@ 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).
- **Observability & performance** *(Stage 12)*`pkg/telemetry` unit-tests the exporter
selection (`none`/`stdout`/`otlp` build providers; OTLP constructs with no collector;
the nil-runtime fallback). The domain metrics are exercised through a manual
`sdkmetric` reader: `backend/internal/game` and `…/social` assert the counters and
histograms record with the right `variant`/`kind` attributes, and
`gateway/internal/connectsrv` asserts `edge_request_duration` by `message_type`/
`result`. Config tests cover the new telemetry env vars (backend/gateway/connector —
`otlp` now accepted, an unsupported exporter rejected) and the guest-reaper knobs.
Postgres-backed `inttest` drives the **guest reaper** end to end (an abandoned guest is
reaped; a too-young guest, a seated guest and a durable account are kept).
## Principles
+4 -2
View File
@@ -5,8 +5,10 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client
is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or
emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themeParams-ready**
(the tokens can be overridden at runtime).
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9):
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
## Layout shell (`components/Screen.svelte`)
+23 -11
View File
@@ -5,9 +5,9 @@ terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It also fronts the backend admin API behind HTTP
Basic-Auth. See [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10,
§12.
client's in-app live channel. It also serves the backend's admin console at `/_gm`
on its public listener behind HTTP Basic-Auth. See
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12.
## Package layout
@@ -19,11 +19,11 @@ internal/config/ # GATEWAY_* env config
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate)
internal/auth/ # Telegram initData HMAC validator (seam + fixtures)
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c
internal/admin/ # Basic-Auth reverse proxy to the backend admin API
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
```
The FlatBuffers payloads and the backend push proto are the shared wire
@@ -39,6 +39,11 @@ operations are unauthenticated and return the minted token. A unary domain
outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge
failures become Connect error codes.
`auth.telegram` validates the Mini App `initData` by calling the **Telegram connector**
(`GATEWAY_CONNECTOR_ADDR`), which holds the bot token; the gateway also routes
out-of-app push to that connector for recipients with no live in-app stream
(ARCHITECTURE.md §10). When `GATEWAY_CONNECTOR_ADDR` is unset, both are disabled.
The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events
@@ -46,24 +51,31 @@ The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
added the play-loop ops; **Stage 8** added the social/account/history ops —
`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
`email.bind.*`, `stats.get`, `game.gcg`, and the `notify` live event — all via the
identical transcode pattern (`transcode_social.go`).
`stats.get`, `game.gcg`, and the `notify` live event — all via the identical
transcode pattern (`transcode_social.go`). **Stage 11** added account linking & merge
`link.email.request/confirm/merge` and `link.telegram.confirm/merge`
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
**superseded** the Stage 8 `email.bind.*` ops, which were removed.
## Configuration
| Variable | Default | Notes |
| --- | --- | --- |
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener |
| `GATEWAY_ADMIN_ADDR` | `:8082` | admin proxy listener (enabled only with creds) |
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener (also serves the admin console at `/_gm`) |
| `GATEWAY_LOG_LEVEL` | `info` | zap level |
| `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL |
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin proxy |
| `GATEWAY_TELEGRAM_BOT_TOKEN` | unset | enable the Telegram auth path |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
| `GATEWAY_CONNECTOR_ADDR` | unset | Telegram connector gRPC address (enables initData validation + out-of-app push) |
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it |
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
| `GATEWAY_SERVICE_NAME` | `scrabble-gateway` | OpenTelemetry `service.name` |
| `GATEWAY_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `GATEWAY_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
120/min·user (burst 40), admin 60/min·IP (burst 20), email-code 5/10 min·IP.
+80 -24
View File
@@ -2,8 +2,8 @@
// the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform /
// email / guest credentials and mints opaque sessions, rate-limits, injects
// X-User-ID when forwarding to the backend over REST, and bridges the backend's
// gRPC push stream to each client's in-app live channel. It also fronts the
// backend admin API behind HTTP Basic-Auth.
// gRPC push stream to each client's in-app live channel. It also serves the
// backend's admin console at /_gm on the public listener behind HTTP Basic-Auth.
package main
import (
@@ -18,19 +18,22 @@ import (
"go.uber.org/zap"
"scrabble/gateway/internal/admin"
"scrabble/gateway/internal/auth"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/connector"
"scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
pkgtel "scrabble/pkg/telemetry"
)
const (
// shutdownTimeout bounds the graceful HTTP shutdown.
shutdownTimeout = 10 * time.Second
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
telemetryShutdownTimeout = 5 * time.Second
// pushReconnectDelay is the pause before re-subscribing to the backend push
// stream after it ends.
pushReconnectDelay = 2 * time.Second
@@ -57,12 +60,27 @@ func main() {
}
}
// run wires the gateway dependencies and serves the public (and optional admin)
// listeners until the context is cancelled.
// run wires the gateway dependencies and serves the public listener (which also
// fronts the admin console at /_gm) until the context is cancelled.
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
tel, err := pkgtel.New(ctx, cfg.Telemetry)
if err != nil {
return err
}
defer func() {
shutdownCtx, sc := context.WithTimeout(context.Background(), telemetryShutdownTimeout)
defer sc()
if err := tel.Shutdown(shutdownCtx); err != nil {
logger.Warn("telemetry shutdown", zap.Error(err))
}
}()
if err := tel.StartRuntimeMetrics(); err != nil {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
backend, err := backendclient.New(cfg.BackendHTTPURL, cfg.BackendGRPCAddr, cfg.BackendTimeout)
if err != nil {
return err
@@ -73,14 +91,33 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
limiter := ratelimit.New()
hub := push.NewHub(0)
var tg auth.TelegramValidator
if cfg.TelegramBotToken != "" {
tg = auth.NewHMACValidator(cfg.TelegramBotToken)
var conn *connector.Client
var validator transcode.TelegramValidator
if cfg.ConnectorAddr != "" {
conn, err = connector.New(cfg.ConnectorAddr)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
validator = conn
} else {
logger.Warn("telegram auth disabled (GATEWAY_TELEGRAM_BOT_TOKEN unset)")
logger.Warn("telegram disabled (GATEWAY_CONNECTOR_ADDR unset)")
}
registry := transcode.NewRegistry(backend, tg)
// The admin console (backend /_gm) is fronted on the public listener behind
// Basic-Auth, enabled when both credentials are set; it is mounted on the edge
// mux so the Connect h2c handler stays the top-level handler.
var adminProxy http.Handler
if cfg.AdminEnabled() {
adminProxy, err = admin.NewProxy(cfg.BackendHTTPURL, cfg.AdminUser, cfg.AdminPassword, logger)
if err != nil {
return err
}
} else {
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
}
registry := transcode.NewRegistry(backend, validator, cfg.DefaultSupportedLanguages...)
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
@@ -89,24 +126,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
})
// Bridge the backend push stream into the fan-out hub.
go runPushPump(ctx, backend, hub, logger)
// Bridge the backend push stream into the fan-out hub (and the out-of-app
// channel via the connector).
go runPushPump(ctx, backend, hub, conn, logger)
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
servers := []*namedServer{{name: "public", srv: public}}
if cfg.AdminEnabled() {
proxy, err := admin.NewProxy(cfg.BackendHTTPURL, cfg.AdminUser, cfg.AdminPassword, logger)
if err != nil {
return err
}
servers = append(servers, &namedServer{name: "admin", srv: &http.Server{Addr: cfg.AdminAddr, Handler: proxy}})
} else {
logger.Info("admin proxy disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
}
logger.Info("gateway starting",
zap.String("http_addr", cfg.HTTPAddr),
zap.String("backend_http", cfg.BackendHTTPURL),
@@ -153,8 +183,10 @@ func runServers(ctx context.Context, cancel context.CancelFunc, servers []*named
}
// runPushPump keeps a backend push subscription open, forwarding every event to
// the hub and re-subscribing after the stream ends, until the context is done.
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, logger *zap.Logger) {
// the hub and re-subscribing after the stream ends, until the context is done. For
// the out-of-app push kinds it also routes events whose recipient has no live
// in-app stream to the platform connector (a nil connector disables that channel).
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, conn *connector.Client, logger *zap.Logger) {
for ctx.Err() == nil {
stream, err := backend.SubscribePush(ctx, gatewayID)
if err != nil {
@@ -178,6 +210,12 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
Payload: ev.GetPayload(),
EventID: ev.GetEventId(),
})
// Out-of-app fallback: when the recipient has no live in-app stream,
// deliver the event over the platform push channel. Done in a goroutine
// so a slow connector never stalls the in-app firehose.
if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) {
go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger)
}
}
if !sleep(ctx, pushReconnectDelay) {
return
@@ -185,6 +223,24 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
}
}
// deliverOutOfApp resolves the recipient's push target and, when they have a
// Telegram identity and have not confined notifications to the app, asks the
// connector to deliver the event. It is best-effort: every failure is logged and
// dropped (the in-app stream remains the primary channel).
func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, logger *zap.Logger) {
target, err := backend.PushTarget(ctx, userID)
if err != nil {
logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err))
return
}
if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) {
return
}
if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil {
logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err))
}
}
// sleep waits for d or until ctx is cancelled, reporting whether it waited the
// full duration.
func sleep(ctx context.Context, d time.Duration) bool {
+4
View File
@@ -14,6 +14,10 @@ require (
)
require (
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
+13 -15
View File
@@ -1,8 +1,11 @@
// Package admin is the gateway's admin surface: HTTP Basic-Auth in front of a
// reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway
// validates the operator credential and forwards authenticated requests to
// backend /api/v1/admin/*; the backend trusts the gateway on this segment. The
// admin API itself is filled in Stage 10.
// Package admin is the gateway's admin edge: HTTP Basic-Auth in front of a reverse
// proxy that forwards the operator's browser to the backend's server-rendered admin
// console under /_gm. The proxy is mounted at /_gm/ on the gateway's public listener
// (below the h2c wrap, see internal/connectsrv) and forwards verbatim — an inbound
// /_gm/<rest> reaches <backendURL>/_gm/<rest>, preserving the inbound Host so the
// backend's same-origin check sees the public origin. The backend trusts the gateway
// on this segment and adds the console's same-origin CSRF guard
// (docs/ARCHITECTURE.md §12).
package admin
import (
@@ -11,17 +14,14 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
"go.uber.org/zap"
)
// backendAdminPrefix is where the backend mounts its admin API.
const backendAdminPrefix = "/api/v1/admin"
// NewProxy returns a handler that checks Basic-Auth against user/password and
// reverse-proxies the request to the backend admin API, mapping an inbound
// /admin/<rest> path to <backendURL>/api/v1/admin/<rest>.
// reverse-proxies the request verbatim to the backend: the inbound path is
// preserved, so /_gm/<rest> reaches <backendURL>/_gm/<rest>. It is mounted at /_gm/
// on the gateway's public listener.
func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) {
target, err := url.Parse(backendURL)
if err != nil {
@@ -32,10 +32,8 @@ func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler,
}
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(target)
rel := strings.TrimPrefix(pr.In.URL.Path, "/admin")
pr.Out.URL.Path = backendAdminPrefix + rel
pr.Out.Host = pr.In.Host
pr.SetURL(target) // backend scheme+host; the inbound /_gm path is preserved
pr.Out.Host = pr.In.Host // keep the public Host for the backend same-origin check
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Warn("admin proxy upstream error", zap.String("path", r.URL.Path), zap.Error(err))

Some files were not shown because too many files have changed in this diff Show More