Compare commits
1 Commits
master
..
3590df28db
| Author | SHA1 | Date | |
|---|---|---|---|
| 3590df28db |
@@ -33,25 +33,16 @@ jobs:
|
|||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Fetch dictionary DAWGs
|
- name: Fetch scrabble-solver (sibling)
|
||||||
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
|
# The engine package consumes scrabble-solver in-process; go.work points
|
||||||
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
|
# its bare module path at this sibling checkout. The repository is public,
|
||||||
# sibling clone). They ship as a release artifact, one semver per set.
|
# so the clone needs no credentials. It tracks master HEAD (see PLAN.md
|
||||||
run: |
|
# TODO-1 for the move to a published, versioned module).
|
||||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
|
||||||
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -75,7 +66,7 @@ jobs:
|
|||||||
- name: test
|
- name: test
|
||||||
# -count=1 disables the test cache so a green run never depends on a
|
# -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
|
# previous runner's cached state. BACKEND_DICT_DIR points the engine
|
||||||
# tests at the DAWGs fetched from the dictionary release.
|
# tests at the committed DAWGs in the sibling checkout.
|
||||||
env:
|
env:
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
|
||||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
||||||
|
|||||||
@@ -35,25 +35,16 @@ jobs:
|
|||||||
# Ryuk (testcontainers' reaper) does not start cleanly on every runner;
|
# Ryuk (testcontainers' reaper) does not start cleanly on every runner;
|
||||||
# the suite's TestMain terminates its own container, so disable it.
|
# the suite's TestMain terminates its own container, so disable it.
|
||||||
TESTCONTAINERS_RYUK_DISABLED: "true"
|
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:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Fetch dictionary DAWGs
|
- name: Fetch scrabble-solver (sibling)
|
||||||
# The DAWGs moved to the scrabble-dictionary repo (the solver is now a
|
# The backend now imports the engine package, which consumes
|
||||||
# versioned module pinned in backend/go.mod, fetched via GOPRIVATE — no
|
# scrabble-solver in-process; go.work points its bare module path at this
|
||||||
# sibling clone). They ship as a release artifact; the engine's untagged
|
# sibling checkout. The repository is public, so the clone needs no
|
||||||
# tests (compiled here too) load them.
|
# credentials. It tracks master HEAD (see PLAN.md TODO-1).
|
||||||
run: |
|
run: git clone --depth 1 https://gitea.iliadenisov.ru/developer/scrabble-solver.git "${GITHUB_WORKSPACE}/../scrabble-solver"
|
||||||
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
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
@@ -65,7 +56,7 @@ jobs:
|
|||||||
# -count=1 disables the test cache; -p=1 -parallel=1 keeps the
|
# -count=1 disables the test cache; -p=1 -parallel=1 keeps the
|
||||||
# container-backed tests serial; the 15-minute timeout bounds a stuck
|
# container-backed tests serial; the 15-minute timeout bounds a stuck
|
||||||
# container pull. The engine package's (untagged) tests also compile and
|
# container pull. The engine package's (untagged) tests also compile and
|
||||||
# run here, so BACKEND_DICT_DIR points them at the DAWGs from the release.
|
# run here, so BACKEND_DICT_DIR points them at the committed DAWGs.
|
||||||
env:
|
env:
|
||||||
BACKEND_DICT_DIR: ${{ github.workspace }}/dawg
|
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
|
||||||
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
|
run: go test -tags=integration -count=1 -p=1 -parallel=1 -timeout=15m ./backend/...
|
||||||
|
|||||||
@@ -43,14 +43,9 @@ independent (see ARCHITECTURE §9.1).
|
|||||||
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **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** |
|
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
|
||||||
| 9 | Telegram integration (bot side-service, deep-link, push) | **done** |
|
| 9 | Telegram integration (bot side-service, deep-link, push) | **done** |
|
||||||
| 10 | Admin & dictionary ops (complaint review, version reload) | **done** |
|
| 10 | Admin & dictionary ops (complaint review, version reload) | todo |
|
||||||
| 11 | Account linking & merge | **done** |
|
| 11 | Account linking & merge | todo |
|
||||||
| 12 | Observability & performance (telemetry, metrics, guest GC) | **done** |
|
| 12 | Polish (observability, perf with evidence, deploy) | todo |
|
||||||
| 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
|
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||||
adds the modules it needs.
|
adds the modules it needs.
|
||||||
@@ -209,103 +204,10 @@ dedupe). High blast-radius — focused regression tests.
|
|||||||
Open details: conflict resolution (active games on both, duplicate friends,
|
Open details: conflict resolution (active games on both, duplicate friends,
|
||||||
display-name collisions); irreversibility/audit; confirm-flow per platform.
|
display-name collisions); irreversibility/audit; confirm-flow per platform.
|
||||||
|
|
||||||
### Stage 12 — Observability & performance
|
### Stage 12 — Polish
|
||||||
Scope: wire a configurable **OTLP** exporter (alongside `none`/`stdout`), shared in a
|
Scope: observability dashboards, evidence-based performance work, prod
|
||||||
new `pkg/telemetry`; add telemetry to the **gateway** and the **Telegram connector**
|
build/deploy.
|
||||||
(providers + `otelgrpc` on the gRPC hops) for parity with the backend; add
|
Open details: deployment target/host; dashboards; load expectations.
|
||||||
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 15–17** 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
|
## Refinements logged during implementation
|
||||||
|
|
||||||
@@ -780,301 +682,39 @@ caddy; prod VPN; rollback.
|
|||||||
- **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/
|
- **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/
|
||||||
`SendToGameChannel` (backend gains its own connector client) for operator
|
`SendToGameChannel` (backend gains its own connector client) for operator
|
||||||
broadcasts to a user and the game channel.
|
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:00–07: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 15–17.)
|
|
||||||
- **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 15–17**. 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)
|
## Deferred TODOs (cross-stage)
|
||||||
|
|
||||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
|
||||||
published as module `gitea.iliadenisov.ru/developer/scrabble-solver` (tagged `v1.0.0`, with
|
give it a real module URL and switch `backend` to a versioned dependency,
|
||||||
`wordlist`/`dictdawg` de-internalised to public packages); `backend/go.mod` pins it, the `go.work`
|
dropping the `go.work` replace and the CI clone. Removes the floating
|
||||||
replace and the CI sibling-clone are gone, and `GOPRIVATE=gitea.iliadenisov.ru/*` fetches it directly
|
`master` dependency accepted for now (Stage 2 interview).
|
||||||
(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
|
||||||
- ~~**TODO-2 — split the solver into engine vs dictionary generator + versioned dictionary
|
dictionary artifacts.** Owner's idea, with the caveats agreed at the Stage 2
|
||||||
artifacts.**~~ **Done in Stage 14.** A new repo `developer/scrabble-dictionary` holds the word-list
|
interview: the split is sound (build-time wordlist→DAWG vs runtime load have
|
||||||
sources + `cmd/builddict` (moved out of the solver, with `dictprep` and the `dictionaries` submodule)
|
different lifecycles and shrink the runtime dependency surface), **but** the
|
||||||
and builds the three DAWGs against the **published solver + pinned `dafsa`/`alphabet` v1.1.0** — the
|
generator must pin the **same** `dafsa`/`alphabet` versions and alphabet
|
||||||
output is **byte-identical** to the solver's committed fixtures, so the index-drift caveat is handled
|
definitions as the runtime engine or the on-disk format / letter indexing
|
||||||
by construction. Delivered as a Gitea **release artifact** `scrabble-dawg-vX.Y.Z.tar.gz` (not
|
drifts and silently corrupts validation. For delivery prefer **Git LFS or an
|
||||||
`go get`; DAWGs are data; **one semver label for the whole set**); the Go workflows download it for
|
artifact store** (Gitea releases / OCI artifact / object storage) over a raw
|
||||||
`BACKEND_DICT_DIR`. The runtime dynamic-reload contract (per-version `BACKEND_DICT_DIR/<version>/` via
|
git submodule (the ~0.5–0.7 MB DAWGs are regenerated wholesale and bloat git
|
||||||
`Registry.LoadAvailable` / `engine.OpenWithVersions`, Stage 10) is unchanged — a deploy drops a new
|
history); pin by tag/hash for a reproducible startup set. A submodule/LFS pull
|
||||||
set into the directory; a version is safe to retire once no active game pins it.
|
is a **deploy-time** way to populate the directory, **not** the runtime
|
||||||
- ~~**TODO-3 — garbage-collect abandoned guest accounts.**~~ **Done in Stage 12.**
|
dynamic-reload mechanism (Stage 10) — keep the `BACKEND_DICT_DIR` directory as
|
||||||
A periodic `account.GuestReaper` deletes guests (`is_guest`) **with no game seat at
|
the runtime contract: a new `.dawg` appears in it and is loaded with
|
||||||
all** whose account age exceeds `BACKEND_GUEST_RETENTION` (default 30 d, swept every
|
`dawg.Load`.
|
||||||
`BACKEND_GUEST_REAP_INTERVAL`, default 1 h). Two schema facts shaped this, narrowing
|
- **TODO-3 — garbage-collect abandoned guest accounts.** Stage 6 makes a guest a
|
||||||
the original sketch: (1) `game_players`/`chat_messages`/`complaints` reference accounts
|
durable `accounts` row (no identity, `is_guest`), so an ephemeral guest leaves a
|
||||||
**without** `ON DELETE CASCADE`, and a finished game belongs to the other players'
|
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
|
||||||
history, so a guest with any seat is retained (a delete would be blocked anyway) — hence
|
guest accounts with no active games once their last session is gone; the
|
||||||
"no seat", not "no active game"; (2) sessions are revoke-only with no maintained
|
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
|
||||||
`last_seen_at`, so a lingering session never expires and **account age** is the
|
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
|
||||||
abandonment trigger, not "last session gone". The reaped guest's `sessions`/`identities`/
|
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
|
||||||
`account_stats` fall away via their own `ON DELETE CASCADE`.
|
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
|
||||||
- ~~**TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).**~~ **Done in
|
letters. Consider extending `game.state` to carry the variant's `(letter, index,
|
||||||
Stage 13.** The client is now alphabet-agnostic: it caches each variant's `(index, letter,
|
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
|
||||||
value)` table — sent by the backend behind `StateRequest.include_alphabet` on a per-variant
|
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
|
||||||
cache miss — and live play exchanges **letter indices** both ways (rack, submit-play,
|
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
|
||||||
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:*
|
- **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
|
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
|
launch, and the UI shows a **share-to-Telegram** link for an issued code when
|
||||||
|
|||||||
+23
-57
@@ -73,31 +73,8 @@ uses to route out-of-app push to the Telegram connector, extends the Telegram lo
|
|||||||
seed a new account's language and display name from the launch fields, and adds
|
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 `00007` (`accounts.notifications_in_app_only`, default true).
|
||||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
||||||
with no identity, excluded from statistics. **Stage 10** adds the server-rendered
|
with no identity, excluded from statistics. The shared wire contracts live in the
|
||||||
**admin console** at `/_gm` (`internal/adminconsole` + `internal/server/handlers_admin_console.go`;
|
sibling [`../pkg`](../pkg) module.
|
||||||
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
|
## Package layout
|
||||||
|
|
||||||
@@ -109,18 +86,14 @@ internal/telemetry/ # OpenTelemetry providers + per-request timing middleware
|
|||||||
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
internal/postgres/ # pgx-over-database/sql pool (otelsql), goose migrations
|
||||||
migrations/ # embedded *.sql (goose), schema `backend`
|
migrations/ # embedded *.sql (goose), schema `backend`
|
||||||
jet/ # generated go-jet models + table builders (committed)
|
jet/ # generated go-jet models + table builders (committed)
|
||||||
internal/account/ # durable accounts + platform/email identities (store) + email/identity link primitives
|
internal/account/ # durable accounts + platform/email identities (store)
|
||||||
internal/accountmerge/ # single-transaction merge of a secondary account into a primary (Stage 11)
|
internal/session/ # opaque tokens, sessions store, write-through cache, service
|
||||||
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/server/ # gin engine, route groups, X-User-ID middleware, probes
|
||||||
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
internal/engine/ # in-process scrabble-solver bridge: registry, bag, Game, replay
|
||||||
internal/game/ # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
|
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/social/ # friend graph, per-user blocks, per-game chat + nudge, content filter
|
||||||
internal/lobby/ # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
|
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/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)
|
## Configuration (environment)
|
||||||
@@ -136,8 +109,8 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b
|
|||||||
| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. |
|
| `BACKEND_POSTGRES_CONN_MAX_LIFETIME` | `30m` | Max connection lifetime. |
|
||||||
| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. |
|
| `BACKEND_POSTGRES_OPERATION_TIMEOUT` | `5s` | Connect attempt + `/readyz` ping bound. |
|
||||||
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
|
| `BACKEND_SERVICE_NAME` | `scrabble-backend` | OpenTelemetry `service.name`. |
|
||||||
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from the standard `OTEL_EXPORTER_OTLP_*`). |
|
| `BACKEND_OTEL_TRACES_EXPORTER` | `none` | `none` or `stdout` (OTLP arrives later). |
|
||||||
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp`. |
|
| `BACKEND_OTEL_METRICS_EXPORTER` | `none` | `none` or `stdout`. |
|
||||||
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
| `BACKEND_DICT_DIR` | — | **Required.** Directory of committed `.dawg` dictionaries. |
|
||||||
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
|
| `BACKEND_DICT_VERSION` | `v1` | Dictionary version new games pin. |
|
||||||
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
| `BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL` | `1m` | How often the turn-timeout sweeper runs. |
|
||||||
@@ -150,19 +123,13 @@ internal/connector/ # backend gRPC client to the Telegram connector (operator b
|
|||||||
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
|
||||||
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
|
||||||
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
|
| `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
|
## Run
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
|
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_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
|
||||||
BACKEND_DICT_DIR=/tmp/dawg \
|
BACKEND_DICT_DIR=../../scrabble-solver/dawg \
|
||||||
GOPRIVATE='gitea.iliadenisov.ru/*' \
|
|
||||||
go run ./cmd/backend
|
go run ./cmd/backend
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -185,18 +152,19 @@ go run ./cmd/jetgen # rewrites internal/postgres/jet against a temp containe
|
|||||||
|
|
||||||
## Engine & dictionaries
|
## Engine & dictionaries
|
||||||
|
|
||||||
`internal/engine` consumes `scrabble-solver` in-process as a **published, versioned
|
`internal/engine` consumes the sibling `scrabble-solver` module in-process. Its
|
||||||
module** (`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `go.mod`). Set
|
bare module path (`scrabble-solver`, not a URL) cannot be fetched via VCS, so the
|
||||||
`GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea (skipping
|
workspace `go.work` carries `replace scrabble-solver => ../scrabble-solver` and
|
||||||
the public proxy/checksum DB); no sibling checkout or `go.work` replace is needed (for
|
the build must run from the repository root (the workspace), not from this module
|
||||||
local solver co-development you may add a temporary replace — see `go.work`).
|
in isolation. `github.com/iliadenisov/dafsa` (the DAWG loader) is a direct
|
||||||
`github.com/iliadenisov/dafsa` (the DAWG loader) is a direct dependency. The dictionaries
|
dependency. CI clones the public solver repository into `../scrabble-solver`
|
||||||
(`en_sowpods.dawg`, `ru_scrabble.dawg`, `ru_erudit.dawg`) ship as a **release artifact**
|
before building (see `.gitea/workflows/`); locally, check it out next to this
|
||||||
from the [`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary)
|
repository. Committed dictionaries (`en_sowpods.dawg`, `ru_scrabble.dawg`,
|
||||||
repo (one semver per set); the engine loads them by `(variant, dict_version)` from
|
`ru_erudit.dawg`) live in the solver's `dawg/` directory; the engine loads them
|
||||||
`BACKEND_DICT_DIR`. Since Stage 3 the backend loads them at startup as a hard dependency
|
by `(variant, dict_version)` from a directory path. Since Stage 3 the backend
|
||||||
(a missing dictionary aborts the boot). See [`../PLAN.md`](../PLAN.md) Stage 14
|
loads them at startup from the **required** `BACKEND_DICT_DIR` (a missing
|
||||||
(TODO-1/TODO-2).
|
dictionary aborts the boot); the future versioned-artifact direction is recorded
|
||||||
|
in [`../PLAN.md`](../PLAN.md) TODO-2.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
@@ -207,8 +175,6 @@ 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
|
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
|
throwaway `postgres:17-alpine` container; they fail loudly when Docker is absent
|
||||||
rather than skipping. The `internal/engine` tests load the DAWGs from
|
rather than skipping. The `internal/engine` tests load the committed DAWGs from
|
||||||
`BACKEND_DICT_DIR` (CI sets it to the extracted dictionary release artifact; locally it
|
`BACKEND_DICT_DIR` (defaulting to the sibling `../scrabble-solver/dawg`) and fail
|
||||||
defaults to a `scrabble-solver/dawg` sibling checkout) and fail loudly when that directory
|
loudly when that directory is absent.
|
||||||
is absent. `GOPRIVATE=gitea.iliadenisov.ru/*` is needed for go to fetch the pinned solver
|
|
||||||
module.
|
|
||||||
|
|||||||
@@ -18,12 +18,9 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
|
||||||
"scrabble/backend/internal/config"
|
"scrabble/backend/internal/config"
|
||||||
"scrabble/backend/internal/connector"
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/link"
|
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/notify"
|
"scrabble/backend/internal/notify"
|
||||||
"scrabble/backend/internal/postgres"
|
"scrabble/backend/internal/postgres"
|
||||||
@@ -80,9 +77,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Warn("telemetry shutdown", zap.Error(err))
|
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,
|
db, err := postgres.Open(ctx, cfg.Postgres,
|
||||||
postgres.WithTracerProvider(tel.TracerProvider()),
|
postgres.WithTracerProvider(tel.TracerProvider()),
|
||||||
@@ -98,7 +92,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
logger.Info("database migrations applied")
|
logger.Info("database migrations applied")
|
||||||
|
|
||||||
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
|
registry, err := engine.Open(cfg.Game.DictDir, cfg.Game.DictVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load dictionaries: %w", err)
|
return fmt.Errorf("load dictionaries: %w", err)
|
||||||
}
|
}
|
||||||
@@ -107,19 +101,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
zap.String("dir", cfg.Game.DictDir),
|
zap.String("dir", cfg.Game.DictDir),
|
||||||
zap.String("version", cfg.Game.DictVersion))
|
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())
|
sessions := session.NewService(session.NewStore(db), session.NewCache())
|
||||||
if err := sessions.Warm(ctx); err != nil {
|
if err := sessions.Warm(ctx); err != nil {
|
||||||
return fmt.Errorf("warm session cache: %w", err)
|
return fmt.Errorf("warm session cache: %w", err)
|
||||||
@@ -134,30 +115,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
accounts := account.NewStore(db)
|
accounts := account.NewStore(db)
|
||||||
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
|
||||||
games.SetNotifier(hub)
|
games.SetNotifier(hub)
|
||||||
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
|
|
||||||
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
|
||||||
logger.Info("game turn-timeout sweeper started",
|
logger.Info("game turn-timeout sweeper started",
|
||||||
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
|
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
|
// 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
|
// the gateway in Stage 6, so they are handed to the server (like the route
|
||||||
// groups) for the handlers to come.
|
// groups) for the handlers to come.
|
||||||
mailer := newMailer(cfg.SMTP, logger)
|
mailer := newMailer(cfg.SMTP, logger)
|
||||||
emails := account.NewEmailService(accounts, mailer)
|
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 := social.NewService(social.NewStore(db), accounts, games)
|
||||||
socialSvc.SetNotifier(hub)
|
socialSvc.SetNotifier(hub)
|
||||||
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
|
|
||||||
|
|
||||||
// Stage 5 robot opponent: provision its durable account pool (a hard startup
|
// Stage 5 robot opponent: provision its durable account pool (a hard startup
|
||||||
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
// dependency, like the dictionaries) and start its move driver. The matchmaker
|
||||||
@@ -188,10 +156,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
Matchmaker: matchmaker,
|
Matchmaker: matchmaker,
|
||||||
Invitations: invitations,
|
Invitations: invitations,
|
||||||
Emails: emails,
|
Emails: emails,
|
||||||
Links: links,
|
|
||||||
Registry: registry,
|
|
||||||
DictDir: cfg.Game.DictDir,
|
|
||||||
Connector: conn,
|
|
||||||
})
|
})
|
||||||
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -3,7 +3,6 @@ module scrabble/backend
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.iliadenisov.ru/developer/scrabble-solver v1.0.0
|
|
||||||
github.com/XSAM/otelsql v0.42.0
|
github.com/XSAM/otelsql v0.42.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-jet/jet/v2 v2.14.1
|
github.com/go-jet/jet/v2 v2.14.1
|
||||||
@@ -21,6 +20,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||||
go.opentelemetry.io/otel/trace v1.43.0
|
go.opentelemetry.io/otel/trace v1.43.0
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
|
scrabble-solver v0.0.0-00010101000000-000000000000
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -101,7 +101,6 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.2.1 // 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.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ type Account struct {
|
|||||||
HintBalance int
|
HintBalance int
|
||||||
BlockChat bool
|
BlockChat bool
|
||||||
BlockFriendRequests 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,
|
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||||
// excluded from statistics, friends and history.
|
// excluded from statistics, friends and history.
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
@@ -69,26 +63,8 @@ type Account struct {
|
|||||||
// true (the default): the platform side-service skips out-of-app push for the
|
// true (the default): the platform side-service skips out-of-app push for the
|
||||||
// account (Stage 9).
|
// account (Stage 9).
|
||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
// PaidAccount marks a lifetime one-time-payment account. It is a service field
|
CreatedAt time.Time
|
||||||
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
|
UpdatedAt time.Time
|
||||||
// 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.
|
// Store is the Postgres-backed query surface for accounts and identities.
|
||||||
@@ -211,54 +187,6 @@ func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kin
|
|||||||
return row.ExternalID, nil
|
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,
|
// findByIdentity joins identities to accounts and returns the matching account,
|
||||||
// or ErrNotFound.
|
// or ErrNotFound.
|
||||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||||
@@ -380,41 +308,12 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
|||||||
return n > 0, nil
|
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.
|
// modelToAccount projects a generated model row into the public Account struct.
|
||||||
func modelToAccount(row model.Accounts) Account {
|
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{
|
return Account{
|
||||||
ID: row.AccountID,
|
ID: row.AccountID,
|
||||||
DisplayName: row.DisplayName,
|
DisplayName: row.DisplayName,
|
||||||
PreferredLanguage: row.PreferredLanguage,
|
PreferredLanguage: row.PreferredLanguage,
|
||||||
ServiceLanguage: serviceLanguage,
|
|
||||||
TimeZone: row.TimeZone,
|
TimeZone: row.TimeZone,
|
||||||
AwayStart: row.AwayStart,
|
AwayStart: row.AwayStart,
|
||||||
AwayEnd: row.AwayEnd,
|
AwayEnd: row.AwayEnd,
|
||||||
@@ -423,8 +322,6 @@ func modelToAccount(row model.Accounts) Account {
|
|||||||
BlockFriendRequests: row.BlockFriendRequests,
|
BlockFriendRequests: row.BlockFriendRequests,
|
||||||
IsGuest: row.IsGuest,
|
IsGuest: row.IsGuest,
|
||||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||||
PaidAccount: row.PaidAccount,
|
|
||||||
MergedInto: mergedInto,
|
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
UpdatedAt: row.UpdatedAt,
|
UpdatedAt: row.UpdatedAt,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
/* 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; }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// 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
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{{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}}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
{{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}}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{with .Data}}
|
|
||||||
<h1>Complaint: {{.Word}}</h1>
|
|
||||||
<nav class="subnav"><a href="/_gm/complaints">« 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}}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
{{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}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
|
||||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
|
||||||
{{if .Pager.HasNext}}<a href="/_gm/complaints?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{{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}}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
{{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/<version>/ 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}}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{with .Data}}
|
|
||||||
<h1>Game {{.ID}}</h1>
|
|
||||||
<nav class="subnav"><a href="/_gm/games">« 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}}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{{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}}&page={{.Pager.PrevPage}}">« prev</a>{{end}}
|
|
||||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
|
||||||
{{if .Pager.HasNext}}<a href="/_gm/games?status={{.Status}}&page={{.Pager.NextPage}}">next »</a>{{end}}
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{with .Data}}
|
|
||||||
<h1>{{.Heading}}</h1>
|
|
||||||
<p>{{.Body}}</p>
|
|
||||||
<p><a href="{{.Back}}">« back</a></p>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
{{define "content" -}}
|
|
||||||
{{with .Data}}
|
|
||||||
<h1>{{.DisplayName}}</h1>
|
|
||||||
<nav class="subnav"><a href="/_gm/users">« 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}}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{{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}}">« prev</a>{{end}}
|
|
||||||
<span>page {{.Pager.Page}} · {{.Pager.Total}} total</span>
|
|
||||||
{{if .Pager.HasNext}}<a href="/_gm/users?page={{.Pager.NextPage}}">next »</a>{{end}}
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
{{- end}}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -38,24 +38,13 @@ type Config struct {
|
|||||||
// SMTP configures the email relay used for confirm-codes. An empty Host
|
// SMTP configures the email relay used for confirm-codes. An empty Host
|
||||||
// selects the development log mailer (the code is logged, not sent).
|
// selects the development log mailer (the code is logged, not sent).
|
||||||
SMTP account.SMTPConfig
|
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.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
const (
|
const (
|
||||||
defaultHTTPAddr = ":8080"
|
defaultHTTPAddr = ":8080"
|
||||||
defaultGRPCAddr = ":9090"
|
defaultGRPCAddr = ":9090"
|
||||||
defaultLogLevel = "info"
|
defaultLogLevel = "info"
|
||||||
defaultGuestReapInterval = time.Hour
|
|
||||||
defaultGuestRetention = 30 * 24 * time.Hour
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the configuration from the environment, applies defaults for unset
|
// Load reads the configuration from the environment, applies defaults for unset
|
||||||
@@ -105,15 +94,6 @@ func Load() (Config, error) {
|
|||||||
return Config{}, err
|
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{
|
smtp := account.SMTPConfig{
|
||||||
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
Host: os.Getenv("BACKEND_SMTP_HOST"),
|
||||||
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
Port: envOr("BACKEND_SMTP_PORT", "587"),
|
||||||
@@ -123,18 +103,15 @@ func Load() (Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
|
||||||
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
|
||||||
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
LogLevel: envOr("BACKEND_LOG_LEVEL", defaultLogLevel),
|
||||||
Postgres: pg,
|
Postgres: pg,
|
||||||
Telemetry: tel,
|
Telemetry: tel,
|
||||||
Game: gm,
|
Game: gm,
|
||||||
Lobby: lb,
|
Lobby: lb,
|
||||||
Robot: rb,
|
Robot: rb,
|
||||||
SMTP: smtp,
|
SMTP: smtp,
|
||||||
ConnectorAddr: os.Getenv("BACKEND_CONNECTOR_ADDR"),
|
|
||||||
GuestReapInterval: guestReapInterval,
|
|
||||||
GuestRetention: guestRetention,
|
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -170,12 +147,6 @@ func (c Config) validate() error {
|
|||||||
if err := c.Robot.Validate(); err != nil {
|
if err := c.Robot.Validate(); err != nil {
|
||||||
return fmt.Errorf("config: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,54 +151,12 @@ func TestLoadRejectsMalformedDuration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the
|
// TestLoadRejectsUnsupportedExporter verifies that an exporter outside the MVP
|
||||||
// supported set is rejected.
|
// set is rejected.
|
||||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
||||||
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
t.Setenv("BACKEND_POSTGRES_DSN", testDSN)
|
||||||
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "prometheus")
|
t.Setenv("BACKEND_OTEL_TRACES_EXPORTER", "otlp")
|
||||||
if _, err := Load(); err == nil {
|
if _, err := Load(); err == nil {
|
||||||
t.Fatal("Load: expected an error for an unsupported exporter, got 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
"scrabble-solver/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// blankTile marks a blank tile in a hand or in the bag, matching the
|
// blankTile marks a blank tile in a hand or in the bag, matching the
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
"scrabble-solver/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// allTiles returns the full multiset of tiles a bag is filled from, in ruleset
|
// allTiles returns the full multiset of tiles a bag is filled from, in ruleset
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
"scrabble-solver/board"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
"scrabble-solver/rules"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ActionKind classifies a turn in the move log.
|
// ActionKind classifies a turn in the move log.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// blankCellFlag is the bit board cells set for a blank tile (board.go encoding).
|
// blankCellFlag is the bit board cells set for a blank tile (board.go encoding).
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
|
// blankLetter is how a blank tile is written in the decoded, domain-facing API:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
"scrabble-solver/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Variant identifies a Scrabble variant the backend offers. Each maps to a
|
// Variant identifies a Scrabble variant the backend offers. Each maps to a
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package engine
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
"scrabble-solver/board"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
|
"scrabble-solver/rack"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
"scrabble-solver/rules"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scorelessLimit is the number of consecutive scoreless turns (passes and
|
// scorelessLimit is the number of consecutive scoreless turns (passes and
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
"scrabble-solver/board"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newEnglishGame starts a two-player English game with the given seed.
|
// newEnglishGame starts a two-player English game with the given seed.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
|
"scrabble-solver/rules"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testVersion labels the single dictionary version the tests register.
|
// testVersion labels the single dictionary version the tests register.
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package engine
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
dawg "github.com/iliadenisov/dafsa"
|
dawg "github.com/iliadenisov/dafsa"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dictFiles maps each variant to its committed DAWG filename, as built by
|
// dictFiles maps each variant to its committed DAWG filename, as built by
|
||||||
@@ -64,36 +63,6 @@ func Open(dir, version string, variants ...Variant) (*Registry, error) {
|
|||||||
return r, nil
|
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
|
// 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
|
// registers it under version. Reloading the same (variant, version) replaces the
|
||||||
// previous entry, closing its finder. The most recently loaded version of a
|
// previous entry, closing its finder. The most recently loaded version of a
|
||||||
@@ -122,29 +91,6 @@ func (r *Registry) Load(v Variant, version, dir string) error {
|
|||||||
return nil
|
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
|
// Solver returns the solver for the (variant, version) pair, or ErrUnknownVariant
|
||||||
// when the variant is absent and ErrUnknownVersion when only the version is.
|
// when the variant is absent and ErrUnknownVersion when only the version is.
|
||||||
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
|
func (r *Registry) Solver(v Variant, version string) (*scrabble.Solver, error) {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
|
"scrabble-solver/board"
|
||||||
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
|
"scrabble-solver/scrabble"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestRegistryOpensEveryVariant checks that Open loads all three variants at the
|
// TestRegistryOpensEveryVariant checks that Open loads all three variants at the
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -63,7 +63,6 @@ type gameCache struct {
|
|||||||
|
|
||||||
type cachedGame struct {
|
type cachedGame struct {
|
||||||
game *engine.Game
|
game *engine.Game
|
||||||
variant string
|
|
||||||
lastAccess time.Time
|
lastAccess time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,12 +82,11 @@ func (c *gameCache) get(id uuid.UUID) (*engine.Game, bool) {
|
|||||||
return e.game, true
|
return e.game, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// put stores g as the live game for id. variant labels the entry so the active-
|
// put stores g as the live game for id.
|
||||||
// games gauge can report counts by variant without inspecting engine internals.
|
func (c *gameCache) put(id uuid.UUID, g *engine.Game) {
|
||||||
func (c *gameCache) put(id uuid.UUID, g *engine.Game, variant string) {
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.entries[id] = &cachedGame{game: g, variant: variant, lastAccess: c.now()}
|
c.entries[id] = &cachedGame{game: g, lastAccess: c.now()}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove drops id from the cache (used on a finished game and after a failed
|
// remove drops id from the cache (used on a finished game and after a failed
|
||||||
@@ -121,16 +119,3 @@ func (c *gameCache) size() int {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
return len(c.entries)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ func TestGameCacheEviction(t *testing.T) {
|
|||||||
cur := time.Unix(1_700_000_000, 0)
|
cur := time.Unix(1_700_000_000, 0)
|
||||||
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
cache := newGameCache(time.Hour, func() time.Time { return cur })
|
||||||
id := uuid.New()
|
id := uuid.New()
|
||||||
cache.put(id, nil, "english")
|
cache.put(id, nil)
|
||||||
if _, ok := cache.get(id); !ok {
|
if _, ok := cache.get(id); !ok {
|
||||||
t.Fatal("game must be resident after put")
|
t.Fatal("game must be resident after put")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -33,7 +33,6 @@ type Service struct {
|
|||||||
clock func() time.Time
|
clock func() time.Time
|
||||||
rng func() int64
|
rng func() int64
|
||||||
pub notify.Publisher
|
pub notify.Publisher
|
||||||
metrics *gameMetrics
|
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ func NewService(store *Store, accounts *account.Store, registry *engine.Registry
|
|||||||
clock: clock,
|
clock: clock,
|
||||||
rng: randomSeed,
|
rng: randomSeed,
|
||||||
pub: notify.Nop{},
|
pub: notify.Nop{},
|
||||||
metrics: defaultGameMetrics(),
|
|
||||||
log: log,
|
log: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,8 +135,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
|
|||||||
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
if err := svc.store.CreateGame(ctx, ins, params.Seats); err != nil {
|
||||||
return Game{}, err
|
return Game{}, err
|
||||||
}
|
}
|
||||||
svc.cache.put(id, g, params.Variant.String())
|
svc.cache.put(id, g)
|
||||||
svc.metrics.recordStarted(ctx, params.Variant)
|
|
||||||
return svc.store.GetGame(ctx, id)
|
return svc.store.GetGame(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,13 +175,6 @@ 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
|
// transition validates the actor and turn, applies op under the per-game lock and
|
||||||
// commits the result.
|
// commits the result.
|
||||||
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
|
func (svc *Service) transition(ctx context.Context, gameID, accountID uuid.UUID, op engineOp) (MoveResult, error) {
|
||||||
@@ -360,7 +350,6 @@ 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 {
|
if _, err := svc.commit(ctx, gameID, g, rec, "timeout", rackBefore, nil, cur.Seats); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
svc.metrics.recordAbandoned(ctx, cur.Variant)
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,9 +373,7 @@ func (svc *Service) EvaluatePlay(ctx context.Context, gameID, accountID uuid.UUI
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return EvalResult{}, err
|
return EvalResult{}, err
|
||||||
}
|
}
|
||||||
validateStart := time.Now()
|
|
||||||
rec, err := g.EvaluatePlay(dir, tiles)
|
rec, err := g.EvaluatePlay(dir, tiles)
|
||||||
svc.metrics.recordValidate(ctx, pre.Variant, validateStart)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, engine.ErrIllegalPlay) {
|
if errors.Is(err, engine.ErrIllegalPlay) {
|
||||||
return EvalResult{Valid: false}, nil
|
return EvalResult{Valid: false}, nil
|
||||||
@@ -433,68 +420,6 @@ 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
|
// 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
|
// turn, spending one hint from their per-game allowance and then their profile
|
||||||
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
// wallet. It returns ErrHintsDisabled, ErrNoHintsLeft or ErrNoHintAvailable as
|
||||||
@@ -658,23 +583,6 @@ func (svc *Service) ListForAccount(ctx context.Context, accountID uuid.UUID) ([]
|
|||||||
return svc.store.ListGamesForAccount(ctx, accountID)
|
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.
|
// History returns a game's full, dictionary-independent move journal.
|
||||||
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
func (svc *Service) History(ctx context.Context, gameID uuid.UUID) (HistoryView, error) {
|
||||||
g, err := svc.store.GetGame(ctx, gameID)
|
g, err := svc.store.GetGame(ctx, gameID)
|
||||||
@@ -717,7 +625,7 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !g.Over() {
|
if !g.Over() {
|
||||||
svc.cache.put(pre.ID, g, pre.Variant.String())
|
svc.cache.put(pre.ID, g)
|
||||||
}
|
}
|
||||||
return g, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
@@ -726,7 +634,6 @@ func (svc *Service) liveGame(ctx context.Context, pre Game) (*engine.Game, error
|
|||||||
// re-applying every journalled move in order. The deterministic bag makes the
|
// re-applying every journalled move in order. The deterministic bag makes the
|
||||||
// reconstruction exact.
|
// reconstruction exact.
|
||||||
func (svc *Service) replay(ctx context.Context, pre Game) (*engine.Game, error) {
|
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)
|
seed, err := svc.store.GameSeed(ctx, pre.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -863,29 +770,6 @@ func normalizeWord(word string) string {
|
|||||||
return strings.ToLower(strings.TrimSpace(word))
|
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
|
// randomSeed returns an unpredictable bag seed, falling back to the clock if the
|
||||||
// system source fails.
|
// system source fails.
|
||||||
func randomSeed() int64 {
|
func randomSeed() int64 {
|
||||||
|
|||||||
+10
-208
@@ -135,24 +135,6 @@ func (s *Store) GetGame(ctx context.Context, id uuid.UUID) (Game, error) {
|
|||||||
return projectGame(grow, srows)
|
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
|
// 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
|
// one game (active or finished). It backs the social package's "befriend an
|
||||||
// opponent" gate via a self-join on game_players.
|
// opponent" gate via a self-join on game_players.
|
||||||
@@ -215,53 +197,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
|
|||||||
return out, nil
|
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.
|
// GetJournal loads the ordered, decoded move journal for a game.
|
||||||
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
|
func (s *Store) GetJournal(ctx context.Context, id uuid.UUID) ([]HistoryMove, error) {
|
||||||
stmt := postgres.SELECT(table.GameMoves.AllColumns).
|
stmt := postgres.SELECT(table.GameMoves.AllColumns).
|
||||||
@@ -449,122 +384,6 @@ func (s *Store) FileComplaint(ctx context.Context, c Complaint) (Complaint, erro
|
|||||||
return projectComplaint(row)
|
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
|
// 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.
|
// filters them against the per-move deadline and the player's away window.
|
||||||
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
func (s *Store) ActiveGames(ctx context.Context) ([]activeGame, error) {
|
||||||
@@ -704,36 +523,19 @@ func projectComplaint(row model.Complaints) (Complaint, error) {
|
|||||||
return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
|
return Complaint{}, fmt.Errorf("game: complaint %s: %w", row.ComplaintID, err)
|
||||||
}
|
}
|
||||||
return Complaint{
|
return Complaint{
|
||||||
ID: row.ComplaintID,
|
ID: row.ComplaintID,
|
||||||
ComplainantID: row.ComplainantID,
|
ComplainantID: row.ComplainantID,
|
||||||
GameID: row.GameID,
|
GameID: row.GameID,
|
||||||
Variant: variant,
|
Variant: variant,
|
||||||
DictVersion: row.DictVersion,
|
DictVersion: row.DictVersion,
|
||||||
Word: row.Word,
|
Word: row.Word,
|
||||||
WasValid: row.WasValid,
|
WasValid: row.WasValid,
|
||||||
Note: row.Note,
|
Note: row.Note,
|
||||||
Status: row.Status,
|
Status: row.Status,
|
||||||
CreatedAt: row.CreatedAt,
|
CreatedAt: row.CreatedAt,
|
||||||
Disposition: row.Disposition,
|
|
||||||
ResolutionNote: row.ResolutionNote,
|
|
||||||
ResolvedAt: row.ResolvedAt,
|
|
||||||
AppliedInVersion: row.AppliedInVersion,
|
|
||||||
}, nil
|
}, 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.
|
// 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 {
|
func withTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
|||||||
@@ -15,23 +15,9 @@ const (
|
|||||||
StatusFinished = "finished"
|
StatusFinished = "finished"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Complaint lifecycle values. A complaint is filed StatusComplaintOpen (Stage 3)
|
// ComplaintStatus values; Stage 10 owns the resolution lifecycle, Stage 3 only
|
||||||
// and closed StatusComplaintResolved by the admin review queue (Stage 10) with a
|
// ever writes StatusComplaintOpen.
|
||||||
// Disposition. The CHECK constraints live in migration 00008.
|
const StatusComplaintOpen = "open"
|
||||||
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,
|
// Sentinel errors returned by the service. Engine errors (engine.ErrIllegalPlay,
|
||||||
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
|
// engine.ErrTilesNotOnRack, engine.ErrGameOver, …) propagate unwrapped from the
|
||||||
@@ -193,9 +179,7 @@ type RobotTurn struct {
|
|||||||
Seed int64
|
Seed int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complaint is a word-check complaint in the admin review queue. It is filed
|
// Complaint is a word-check complaint awaiting admin review (Stage 10).
|
||||||
// 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 {
|
type Complaint struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
ComplainantID uuid.UUID
|
ComplainantID uuid.UUID
|
||||||
@@ -207,24 +191,4 @@ type Complaint struct {
|
|||||||
Note string
|
Note string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,46 +155,6 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
// TestIdentityExternalID covers the reverse identity lookup the push-target route
|
||||||
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
||||||
// including for a guest that carries no identity.
|
// including for a guest that carries no identity.
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
//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¬e=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()
|
|
||||||
}
|
|
||||||
@@ -312,13 +312,6 @@ func TestTimeoutSweep(t *testing.T) {
|
|||||||
}
|
}
|
||||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||||
|
|
||||||
// Disable the to-move account's away window: with the default 00:00–07: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,
|
// The sweep is global over the shared pool; assert the target game itself,
|
||||||
// not the count, since other tests leave active games behind.
|
// not the count, since other tests leave active games behind.
|
||||||
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||||
@@ -428,26 +421,6 @@ 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.
|
// TestCheckWordAndComplaint covers the word-check tool and complaint capture.
|
||||||
func TestCheckWordAndComplaint(t *testing.T) {
|
func TestCheckWordAndComplaint(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
//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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -26,8 +26,4 @@ type Accounts struct {
|
|||||||
HintBalance int32
|
HintBalance int32
|
||||||
IsGuest bool
|
IsGuest bool
|
||||||
NotificationsInAppOnly bool
|
NotificationsInAppOnly bool
|
||||||
PaidAccount bool
|
|
||||||
MergedInto *uuid.UUID
|
|
||||||
MergedAt *time.Time
|
|
||||||
ServiceLanguage *string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,18 +13,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Complaints struct {
|
type Complaints struct {
|
||||||
ComplaintID uuid.UUID `sql:"primary_key"`
|
ComplaintID uuid.UUID `sql:"primary_key"`
|
||||||
ComplainantID uuid.UUID
|
ComplainantID uuid.UUID
|
||||||
GameID uuid.UUID
|
GameID uuid.UUID
|
||||||
Variant string
|
Variant string
|
||||||
DictVersion string
|
DictVersion string
|
||||||
Word string
|
Word string
|
||||||
WasValid bool
|
WasValid bool
|
||||||
Note string
|
Note string
|
||||||
Status string
|
Status string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Disposition string
|
|
||||||
ResolutionNote string
|
|
||||||
ResolvedAt *time.Time
|
|
||||||
AppliedInVersion string
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ type accountsTable struct {
|
|||||||
HintBalance postgres.ColumnInteger
|
HintBalance postgres.ColumnInteger
|
||||||
IsGuest postgres.ColumnBool
|
IsGuest postgres.ColumnBool
|
||||||
NotificationsInAppOnly postgres.ColumnBool
|
NotificationsInAppOnly postgres.ColumnBool
|
||||||
PaidAccount postgres.ColumnBool
|
|
||||||
MergedInto postgres.ColumnString
|
|
||||||
MergedAt postgres.ColumnTimestampz
|
|
||||||
ServiceLanguage postgres.ColumnString
|
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -88,13 +84,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||||
IsGuestColumn = postgres.BoolColumn("is_guest")
|
IsGuestColumn = postgres.BoolColumn("is_guest")
|
||||||
NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only")
|
NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only")
|
||||||
PaidAccountColumn = postgres.BoolColumn("paid_account")
|
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||||
MergedIntoColumn = postgres.StringColumn("merged_into")
|
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||||
MergedAtColumn = postgres.TimestampzColumn("merged_at")
|
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||||
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{
|
return accountsTable{
|
||||||
@@ -114,10 +106,6 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
|||||||
HintBalance: HintBalanceColumn,
|
HintBalance: HintBalanceColumn,
|
||||||
IsGuest: IsGuestColumn,
|
IsGuest: IsGuestColumn,
|
||||||
NotificationsInAppOnly: NotificationsInAppOnlyColumn,
|
NotificationsInAppOnly: NotificationsInAppOnlyColumn,
|
||||||
PaidAccount: PaidAccountColumn,
|
|
||||||
MergedInto: MergedIntoColumn,
|
|
||||||
MergedAt: MergedAtColumn,
|
|
||||||
ServiceLanguage: ServiceLanguageColumn,
|
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -17,20 +17,16 @@ type complaintsTable struct {
|
|||||||
postgres.Table
|
postgres.Table
|
||||||
|
|
||||||
// Columns
|
// Columns
|
||||||
ComplaintID postgres.ColumnString
|
ComplaintID postgres.ColumnString
|
||||||
ComplainantID postgres.ColumnString
|
ComplainantID postgres.ColumnString
|
||||||
GameID postgres.ColumnString
|
GameID postgres.ColumnString
|
||||||
Variant postgres.ColumnString
|
Variant postgres.ColumnString
|
||||||
DictVersion postgres.ColumnString
|
DictVersion postgres.ColumnString
|
||||||
Word postgres.ColumnString
|
Word postgres.ColumnString
|
||||||
WasValid postgres.ColumnBool
|
WasValid postgres.ColumnBool
|
||||||
Note postgres.ColumnString
|
Note postgres.ColumnString
|
||||||
Status postgres.ColumnString
|
Status postgres.ColumnString
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
Disposition postgres.ColumnString
|
|
||||||
ResolutionNote postgres.ColumnString
|
|
||||||
ResolvedAt postgres.ColumnTimestampz
|
|
||||||
AppliedInVersion postgres.ColumnString
|
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -72,43 +68,35 @@ func newComplaintsTable(schemaName, tableName, alias string) *ComplaintsTable {
|
|||||||
|
|
||||||
func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable {
|
func newComplaintsTableImpl(schemaName, tableName, alias string) complaintsTable {
|
||||||
var (
|
var (
|
||||||
ComplaintIDColumn = postgres.StringColumn("complaint_id")
|
ComplaintIDColumn = postgres.StringColumn("complaint_id")
|
||||||
ComplainantIDColumn = postgres.StringColumn("complainant_id")
|
ComplainantIDColumn = postgres.StringColumn("complainant_id")
|
||||||
GameIDColumn = postgres.StringColumn("game_id")
|
GameIDColumn = postgres.StringColumn("game_id")
|
||||||
VariantColumn = postgres.StringColumn("variant")
|
VariantColumn = postgres.StringColumn("variant")
|
||||||
DictVersionColumn = postgres.StringColumn("dict_version")
|
DictVersionColumn = postgres.StringColumn("dict_version")
|
||||||
WordColumn = postgres.StringColumn("word")
|
WordColumn = postgres.StringColumn("word")
|
||||||
WasValidColumn = postgres.BoolColumn("was_valid")
|
WasValidColumn = postgres.BoolColumn("was_valid")
|
||||||
NoteColumn = postgres.StringColumn("note")
|
NoteColumn = postgres.StringColumn("note")
|
||||||
StatusColumn = postgres.StringColumn("status")
|
StatusColumn = postgres.StringColumn("status")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
DispositionColumn = postgres.StringColumn("disposition")
|
allColumns = postgres.ColumnList{ComplaintIDColumn, ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
|
||||||
ResolutionNoteColumn = postgres.StringColumn("resolution_note")
|
mutableColumns = postgres.ColumnList{ComplainantIDColumn, GameIDColumn, VariantColumn, DictVersionColumn, WordColumn, WasValidColumn, NoteColumn, StatusColumn, CreatedAtColumn}
|
||||||
ResolvedAtColumn = postgres.TimestampzColumn("resolved_at")
|
defaultColumns = postgres.ColumnList{NoteColumn, StatusColumn, CreatedAtColumn}
|
||||||
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{
|
return complaintsTable{
|
||||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||||
|
|
||||||
//Columns
|
//Columns
|
||||||
ComplaintID: ComplaintIDColumn,
|
ComplaintID: ComplaintIDColumn,
|
||||||
ComplainantID: ComplainantIDColumn,
|
ComplainantID: ComplainantIDColumn,
|
||||||
GameID: GameIDColumn,
|
GameID: GameIDColumn,
|
||||||
Variant: VariantColumn,
|
Variant: VariantColumn,
|
||||||
DictVersion: DictVersionColumn,
|
DictVersion: DictVersionColumn,
|
||||||
Word: WordColumn,
|
Word: WordColumn,
|
||||||
WasValid: WasValidColumn,
|
WasValid: WasValidColumn,
|
||||||
Note: NoteColumn,
|
Note: NoteColumn,
|
||||||
Status: StatusColumn,
|
Status: StatusColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
Disposition: DispositionColumn,
|
|
||||||
ResolutionNote: ResolutionNoteColumn,
|
|
||||||
ResolvedAt: ResolvedAtColumn,
|
|
||||||
AppliedInVersion: AppliedInVersionColumn,
|
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
-- +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;
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
-- +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;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
-- +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;
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ func NewServer(addr string, hub *notify.Hub, log *zap.Logger) *Server {
|
|||||||
if log == nil {
|
if log == nil {
|
||||||
log = zap.NewNop()
|
log = zap.NewNop()
|
||||||
}
|
}
|
||||||
gs := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
|
gs := grpc.NewServer()
|
||||||
pushv1.RegisterPushServer(gs, NewService(hub, log))
|
pushv1.RegisterPushServer(gs, NewService(hub, log))
|
||||||
return &Server{grpc: gs, addr: addr, log: log}
|
return &Server{grpc: gs, addr: addr, log: log}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,24 +101,13 @@ type moveResultDTO struct {
|
|||||||
Game gameDTO `json:"game"`
|
Game gameDTO `json:"game"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// alphabetEntryDTO is one letter of a variant's alphabet (its index, concrete letter and
|
// stateDTO is a player's view of a game.
|
||||||
// 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 {
|
type stateDTO struct {
|
||||||
Game gameDTO `json:"game"`
|
Game gameDTO `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []int `json:"rack"`
|
Rack []string `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
Alphabet []alphabetEntryDTO `json:"alphabet,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchDTO reports whether the caller has been paired into a game.
|
// matchDTO reports whether the caller has been paired into a game.
|
||||||
@@ -228,32 +217,15 @@ func moveResultDTOFrom(r game.MoveResult) moveResultDTO {
|
|||||||
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
return moveResultDTO{Move: moveRecordDTOFrom(r.Move), Game: gameDTOFromGame(r.Game)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateDTOFrom projects a player's state view into its DTO, encoding the rack as wire
|
// stateDTOFrom projects a player's state view into its DTO.
|
||||||
// alphabet indices (Stage 13). When includeAlphabet is set it also embeds the variant's
|
func stateDTOFrom(v game.StateView) stateDTO {
|
||||||
// display table, which the client caches per variant and renders the rack with.
|
return stateDTO{
|
||||||
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),
|
Game: gameDTOFromGame(v.Game),
|
||||||
Seat: v.Seat,
|
Seat: v.Seat,
|
||||||
Rack: rack,
|
Rack: v.Rack,
|
||||||
BagLen: v.BagLen,
|
BagLen: v.BagLen,
|
||||||
HintsRemaining: v.HintsRemaining,
|
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.
|
// matchDTOFrom projects an enqueue/poll result into its DTO.
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/accountmerge"
|
|
||||||
"scrabble/backend/internal/engine"
|
"scrabble/backend/internal/engine"
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
@@ -43,15 +42,9 @@ func (s *Server) registerRoutes() {
|
|||||||
u.PUT("/profile", s.handleUpdateProfile)
|
u.PUT("/profile", s.handleUpdateProfile)
|
||||||
u.GET("/stats", s.handleStats)
|
u.GET("/stats", s.handleStats)
|
||||||
}
|
}
|
||||||
if s.links != nil {
|
if s.emails != nil {
|
||||||
// Account linking & merge (Stage 11). The request step always mails a code;
|
u.POST("/email/request", s.handleEmailBindRequest)
|
||||||
// a required merge is revealed only after the code is verified, and the
|
u.POST("/email/confirm", s.handleEmailBindConfirm)
|
||||||
// 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 {
|
if s.games != nil {
|
||||||
u.GET("/games", s.handleListGames)
|
u.GET("/games", s.handleListGames)
|
||||||
@@ -94,6 +87,7 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/blocks", s.handleBlock)
|
u.POST("/blocks", s.handleBlock)
|
||||||
u.DELETE("/blocks/:id", s.handleUnblock)
|
u.DELETE("/blocks/:id", s.handleUnblock)
|
||||||
}
|
}
|
||||||
|
s.admin.GET("/ping", s.handleAdminPing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userID returns the authenticated account id stored by RequireUserID. The user
|
// userID returns the authenticated account id stored by RequireUserID. The user
|
||||||
@@ -186,10 +180,8 @@ func statusForError(err error) (int, string) {
|
|||||||
return http.StatusConflict, "hint_unavailable"
|
return http.StatusConflict, "hint_unavailable"
|
||||||
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
|
||||||
return http.StatusUnprocessableEntity, "illegal_play"
|
return http.StatusUnprocessableEntity, "illegal_play"
|
||||||
case errors.Is(err, account.ErrEmailTaken), errors.Is(err, account.ErrIdentityTaken):
|
case errors.Is(err, account.ErrEmailTaken):
|
||||||
return http.StatusConflict, "email_taken"
|
return http.StatusConflict, "email_taken"
|
||||||
case errors.Is(err, accountmerge.ErrActiveGameConflict):
|
|
||||||
return http.StatusConflict, "merge_active_game_conflict"
|
|
||||||
case errors.Is(err, account.ErrInvalidEmail):
|
case errors.Is(err, account.ErrInvalidEmail):
|
||||||
return http.StatusBadRequest, "invalid_email"
|
return http.StatusBadRequest, "invalid_email"
|
||||||
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ type statsDTO struct {
|
|||||||
MaxWordPoints int `json:"max_word_points"`
|
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.
|
// parseAwayTime parses an "HH:MM" away-window bound.
|
||||||
func parseAwayTime(s string) (time.Time, bool) {
|
func parseAwayTime(s string) (time.Time, bool) {
|
||||||
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
|
t, err := time.Parse(awayTimeLayout, strings.TrimSpace(s))
|
||||||
@@ -106,3 +117,43 @@ func (s *Server) handleStats(c *gin.Context) {
|
|||||||
MaxWordPoints: st.MaxWordPoints,
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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"})
|
||||||
|
}
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -19,20 +19,16 @@ import (
|
|||||||
// telegramAuthRequest carries the identity the connector extracted from a
|
// telegramAuthRequest carries the identity the connector extracted from a
|
||||||
// validated initData payload. Username, FirstName and LanguageCode seed a
|
// validated initData payload. Username, FirstName and LanguageCode seed a
|
||||||
// brand-new account's display name and language (first contact only).
|
// 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 {
|
type telegramAuthRequest struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
FirstName string `json:"first_name"`
|
FirstName string `json:"first_name"`
|
||||||
LanguageCode string `json:"language_code"`
|
LanguageCode string `json:"language_code"`
|
||||||
ServiceLanguage string `json:"service_language"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||||
// identity and mints a session for it, seeding a new account's display name and
|
// 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
|
// language from the supplied Telegram fields.
|
||||||
// service language (updated every login) so out-of-app push routes to that bot.
|
|
||||||
func (s *Server) handleTelegramAuth(c *gin.Context) {
|
func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||||
var req telegramAuthRequest
|
var req telegramAuthRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||||
@@ -44,10 +40,6 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.accounts.SetServiceLanguage(c.Request.Context(), acc.ID, req.ServiceLanguage); err != nil {
|
|
||||||
s.abortErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mintSession(c, acc)
|
s.mintSession(c, acc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,10 +50,8 @@ type pushTargetRequest struct {
|
|||||||
|
|
||||||
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
|
// 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
|
// 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
|
// identity, e.g. a guest or email-only account), the preferred language for the
|
||||||
// delivering bot and renders the message (the account's service language, the bot
|
// message template, and whether they confined notifications to the in-app stream.
|
||||||
// it last signed in through, falling back to its preferred language), and whether
|
|
||||||
// they confined notifications to the in-app stream.
|
|
||||||
type pushTargetResponse struct {
|
type pushTargetResponse struct {
|
||||||
ExternalID string `json:"external_id"`
|
ExternalID string `json:"external_id"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language"`
|
||||||
@@ -93,15 +83,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, pushTargetResponse{
|
||||||
ExternalID: ext,
|
ExternalID: ext,
|
||||||
Language: language,
|
Language: acc.PreferredLanguage,
|
||||||
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -52,10 +51,9 @@ type chatListDTO struct {
|
|||||||
Messages []chatDTO `json:"messages"`
|
Messages []chatDTO `json:"messages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// exchangeRequest swaps the given rack tiles back into the bag. Tiles are wire alphabet
|
// exchangeRequest swaps the given rack tiles back into the bag.
|
||||||
// indices (Stage 13); a blank is engine.BlankIndex.
|
|
||||||
type exchangeRequest struct {
|
type exchangeRequest struct {
|
||||||
Tiles []int `json:"tiles"`
|
Tiles []string `json:"tiles"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// complaintRequest disputes a word-check result.
|
// complaintRequest disputes a word-check result.
|
||||||
@@ -127,17 +125,7 @@ func (s *Server) handleExchange(c *gin.Context) {
|
|||||||
abortBadRequest(c, "invalid request body")
|
abortBadRequest(c, "invalid request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
res, err := s.games.Exchange(c.Request.Context(), gameID, uid, req.Tiles)
|
||||||
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 {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
@@ -192,15 +180,9 @@ func (s *Server) handleEvaluate(c *gin.Context) {
|
|||||||
abortBadRequest(c, "dir must be H or V")
|
abortBadRequest(c, "dir must be H or V")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
|
||||||
if err != nil {
|
for _, t := range req.Tiles {
|
||||||
s.abortErr(c, err)
|
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||||
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)
|
ev, err := s.games.EvaluatePlay(c.Request.Context(), gameID, uid, dir, tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -210,29 +192,13 @@ func (s *Server) handleEvaluate(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, evalResultDTO{Legal: ev.Valid, Score: ev.Score, Words: ev.Words})
|
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. The word arrives as
|
// handleCheckWord looks a word up in the game's pinned dictionary.
|
||||||
// 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) {
|
func (s *Server) handleCheckWord(c *gin.Context) {
|
||||||
_, gameID, ok := s.userGame(c)
|
_, gameID, ok := s.userGame(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idx, err := queryIndexes(c, "idx")
|
word := c.Query("word")
|
||||||
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)
|
legal, err := s.games.CheckWord(c.Request.Context(), gameID, word)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
@@ -241,21 +207,6 @@ func (s *Server) handleCheckWord(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, wordCheckDTO{Word: word, Legal: legal})
|
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.
|
// handleComplaint files a word-check complaint into the admin review queue.
|
||||||
func (s *Server) handleComplaint(c *gin.Context) {
|
func (s *Server) handleComplaint(c *gin.Context) {
|
||||||
uid, gameID, ok := s.userGame(c)
|
uid, gameID, ok := s.userGame(c)
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -43,6 +43,13 @@ func do(t *testing.T, s *Server, method, path, body string, headers map[string]s
|
|||||||
return rec
|
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) {
|
func TestProfileRequiresUserID(t *testing.T) {
|
||||||
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
|
rec := do(t, newRoutingServer(), http.MethodGet, "/api/v1/user/profile", "", nil)
|
||||||
if rec.Code != http.StatusUnauthorized {
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
|||||||
@@ -26,33 +26,17 @@ func (s *Server) handleProfile(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, profileResponseFor(acc))
|
c.JSON(http.StatusOK, profileResponseFor(acc))
|
||||||
}
|
}
|
||||||
|
|
||||||
// submitPlayRequest places tiles in a direction on the player's turn. Each tile's Letter
|
// submitPlayRequest places tiles in a direction on the player's turn.
|
||||||
// is a wire alphabet index (Stage 13); for a blank it is the designated letter's index.
|
|
||||||
type submitPlayRequest struct {
|
type submitPlayRequest struct {
|
||||||
Dir string `json:"dir"`
|
Dir string `json:"dir"`
|
||||||
Tiles []struct {
|
Tiles []struct {
|
||||||
Row int `json:"row"`
|
Row int `json:"row"`
|
||||||
Col int `json:"col"`
|
Col int `json:"col"`
|
||||||
Letter int `json:"letter"`
|
Letter string `json:"letter"`
|
||||||
Blank bool `json:"blank"`
|
Blank bool `json:"blank"`
|
||||||
} `json:"tiles"`
|
} `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.
|
// handleSubmitPlay validates, scores and commits a placement.
|
||||||
func (s *Server) handleSubmitPlay(c *gin.Context) {
|
func (s *Server) handleSubmitPlay(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
@@ -75,15 +59,9 @@ func (s *Server) handleSubmitPlay(c *gin.Context) {
|
|||||||
abortBadRequest(c, "dir must be H or V")
|
abortBadRequest(c, "dir must be H or V")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
variant, err := s.games.GameVariant(c.Request.Context(), gameID)
|
tiles := make([]engine.TileRecord, 0, len(req.Tiles))
|
||||||
if err != nil {
|
for _, t := range req.Tiles {
|
||||||
s.abortErr(c, err)
|
tiles = append(tiles, engine.TileRecord{Row: t.Row, Col: t.Col, Letter: t.Letter, Blank: t.Blank})
|
||||||
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)
|
res, err := s.games.SubmitPlay(c.Request.Context(), gameID, uid, dir, tiles)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -110,11 +88,7 @@ func (s *Server) handleGameState(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dto, err := stateDTOFrom(view, c.Query("include_alphabet") == "true")
|
dto := stateDTOFrom(view)
|
||||||
if err != nil {
|
|
||||||
s.abortErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
s.fillSeatNames(c.Request.Context(), &dto.Game, map[string]string{})
|
||||||
c.JSON(http.StatusOK, dto)
|
c.JSON(http.StatusOK, dto)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -40,40 +39,3 @@ func UserIDFromContext(ctx context.Context) (uuid.UUID, bool) {
|
|||||||
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
|
id, ok := ctx.Value(userIDContextKey).(uuid.UUID)
|
||||||
return id, ok
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,11 +18,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/account"
|
"scrabble/backend/internal/account"
|
||||||
"scrabble/backend/internal/adminconsole"
|
|
||||||
"scrabble/backend/internal/connector"
|
|
||||||
"scrabble/backend/internal/engine"
|
|
||||||
"scrabble/backend/internal/game"
|
"scrabble/backend/internal/game"
|
||||||
"scrabble/backend/internal/link"
|
|
||||||
"scrabble/backend/internal/lobby"
|
"scrabble/backend/internal/lobby"
|
||||||
"scrabble/backend/internal/session"
|
"scrabble/backend/internal/session"
|
||||||
"scrabble/backend/internal/social"
|
"scrabble/backend/internal/social"
|
||||||
@@ -59,18 +55,6 @@ type Deps struct {
|
|||||||
Matchmaker *lobby.Matchmaker
|
Matchmaker *lobby.Matchmaker
|
||||||
Invitations *lobby.InvitationService
|
Invitations *lobby.InvitationService
|
||||||
Emails *account.EmailService
|
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
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -89,15 +73,11 @@ type Server struct {
|
|||||||
matchmaker *lobby.Matchmaker
|
matchmaker *lobby.Matchmaker
|
||||||
invitations *lobby.InvitationService
|
invitations *lobby.InvitationService
|
||||||
emails *account.EmailService
|
emails *account.EmailService
|
||||||
links *link.Service
|
|
||||||
registry *engine.Registry
|
|
||||||
dictDir string
|
|
||||||
connector *connector.Client
|
|
||||||
console *adminconsole.Renderer
|
|
||||||
|
|
||||||
public *gin.RouterGroup
|
public *gin.RouterGroup
|
||||||
user *gin.RouterGroup
|
user *gin.RouterGroup
|
||||||
internal *gin.RouterGroup
|
internal *gin.RouterGroup
|
||||||
|
admin *gin.RouterGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns a Server that will listen on addr. It installs the recovery and
|
// New returns a Server that will listen on addr. It installs the recovery and
|
||||||
@@ -129,16 +109,11 @@ func New(addr string, deps Deps) *Server {
|
|||||||
matchmaker: deps.Matchmaker,
|
matchmaker: deps.Matchmaker,
|
||||||
invitations: deps.Invitations,
|
invitations: deps.Invitations,
|
||||||
emails: deps.Emails,
|
emails: deps.Emails,
|
||||||
links: deps.Links,
|
|
||||||
registry: deps.Registry,
|
|
||||||
dictDir: deps.DictDir,
|
|
||||||
connector: deps.Connector,
|
|
||||||
http: &http.Server{Addr: addr, Handler: engine},
|
http: &http.Server{Addr: addr, Handler: engine},
|
||||||
}
|
}
|
||||||
s.registerProbes(engine)
|
s.registerProbes(engine)
|
||||||
s.registerAPIGroups(engine)
|
s.registerAPIGroups(engine)
|
||||||
s.registerRoutes()
|
s.registerRoutes()
|
||||||
s.registerConsole(engine)
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +153,7 @@ func (s *Server) registerAPIGroups(engine *gin.Engine) {
|
|||||||
s.user = v1.Group("/user")
|
s.user = v1.Group("/user")
|
||||||
s.user.Use(RequireUserID())
|
s.user.Use(RequireUserID())
|
||||||
s.internal = v1.Group("/internal")
|
s.internal = v1.Group("/internal")
|
||||||
|
s.admin = v1.Group("/admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicGroup returns the unauthenticated public route group.
|
// PublicGroup returns the unauthenticated public route group.
|
||||||
@@ -189,6 +165,9 @@ func (s *Server) UserGroup() *gin.RouterGroup { return s.user }
|
|||||||
// InternalGroup returns the gateway-facing internal route group.
|
// InternalGroup returns the gateway-facing internal route group.
|
||||||
func (s *Server) InternalGroup() *gin.RouterGroup { return s.internal }
|
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.
|
// Social returns the social domain service for the handlers added in Stage 6.
|
||||||
func (s *Server) Social() *social.Service { return s.social }
|
func (s *Server) Social() *social.Service { return s.social }
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Cache is the in-memory write-through projection of the active rows in
|
// Cache is the in-memory write-through projection of the active rows in
|
||||||
@@ -95,19 +93,3 @@ func (c *Cache) Remove(tokenHash string) {
|
|||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
delete(c.byHash, tokenHash)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -71,14 +71,3 @@ func (svc *Service) Revoke(ctx context.Context, token string) error {
|
|||||||
svc.cache.Remove(hash)
|
svc.cache.Remove(hash)
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -110,34 +110,6 @@ func (s *Store) RevokeByTokenHash(ctx context.Context, tokenHash string, at time
|
|||||||
return modelToSession(row), true, nil
|
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.
|
// ListActive loads every active session. Cache.Warm calls this at boot.
|
||||||
func (s *Store) ListActive(ctx context.Context) ([]Session, error) {
|
func (s *Store) ListActive(ctx context.Context) ([]Session, error) {
|
||||||
stmt := postgres.SELECT(table.Sessions.AllColumns).
|
stmt := postgres.SELECT(table.Sessions.AllColumns).
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ func (svc *Service) PostMessage(ctx context.Context, gameID, senderID uuid.UUID,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
svc.metrics.recordChat(ctx, kindMessage)
|
|
||||||
svc.emitChat(seats, senderID, msg)
|
svc.emitChat(seats, senderID, msg)
|
||||||
return msg, nil
|
return msg, nil
|
||||||
}
|
}
|
||||||
@@ -111,7 +110,6 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Message{}, err
|
return Message{}, err
|
||||||
}
|
}
|
||||||
svc.metrics.recordChat(ctx, kindNudge)
|
|
||||||
if toMove >= 0 && toMove < len(seats) {
|
if toMove >= 0 && toMove < len(seats) {
|
||||||
svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID))
|
svc.pub.Publish(notify.Nudge(seats[toMove], gameID, senderID))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
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)))
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -76,7 +76,6 @@ type Service struct {
|
|||||||
accounts *account.Store
|
accounts *account.Store
|
||||||
games GameReader
|
games GameReader
|
||||||
pub notify.Publisher
|
pub notify.Publisher
|
||||||
metrics *socialMetrics
|
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +87,6 @@ func NewService(store *Store, accounts *account.Store, games GameReader) *Servic
|
|||||||
accounts: accounts,
|
accounts: accounts,
|
||||||
games: games,
|
games: games,
|
||||||
pub: notify.Nop{},
|
pub: notify.Nop{},
|
||||||
metrics: defaultSocialMetrics(),
|
|
||||||
now: func() time.Time { return time.Now().UTC() },
|
now: func() time.Time { return time.Now().UTC() },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,158 @@
|
|||||||
// Package telemetry owns the backend's OpenTelemetry wiring. The provider
|
// Package telemetry owns the OpenTelemetry runtime for the backend process.
|
||||||
// bootstrap (exporter selection, propagators, shutdown, Go runtime metrics) is
|
//
|
||||||
// shared across the Scrabble services in scrabble/pkg/telemetry; this package is a
|
// New constructs the configured tracer and meter providers, registers them as
|
||||||
// thin backend-flavoured facade over it (the "scrabble-backend" default service
|
// the OpenTelemetry globals, and exposes Shutdown for orderly exit. The MVP
|
||||||
// name) plus the backend-specific gin request-timing middleware (middleware.go),
|
// supports the `none` and `stdout` exporters; OTLP export and dashboards arrive
|
||||||
// which uses the registered global tracer so requests are timed and logged even
|
// in a later stage. The per-request timing middleware lives in middleware.go and
|
||||||
// when the exporter is "none".
|
// uses the registered global tracer, so requests are timed and logged even when
|
||||||
|
// the exporter is `none`.
|
||||||
package telemetry
|
package telemetry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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.opentelemetry.io/otel/trace"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Exporter selectors, re-exported from scrabble/pkg/telemetry so the backend's
|
// Exporter selectors supported by the backend.
|
||||||
// config and tests need not import the shared package directly.
|
|
||||||
const (
|
const (
|
||||||
ExporterNone = pkgtel.ExporterNone
|
ExporterNone = "none"
|
||||||
ExporterStdout = pkgtel.ExporterStdout
|
ExporterStdout = "stdout"
|
||||||
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"
|
const DefaultServiceName = "scrabble-backend"
|
||||||
|
|
||||||
// Config selects the telemetry providers' service name and exporters. It aliases
|
// Config selects the telemetry providers' service name and exporters.
|
||||||
// the shared configuration type.
|
type Config struct {
|
||||||
type Config = pkgtel.Config
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
// Runtime owns the shared OpenTelemetry providers. It aliases the shared runtime
|
// DefaultConfig returns the MVP telemetry configuration: named service, no
|
||||||
// type, so callers keep using telemetry.Runtime.
|
// exporters (so no collector is required locally or in CI).
|
||||||
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 {
|
func DefaultConfig() Config {
|
||||||
return pkgtel.DefaultConfig(DefaultServiceName)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs the telemetry runtime, registers the global providers and the
|
// New constructs the telemetry runtime, registers the global providers and the
|
||||||
// W3C propagators, and returns the Runtime. Callers must invoke Runtime.Shutdown
|
// W3C trace-context/baggage propagators, and returns the Runtime. Callers must
|
||||||
// during process exit.
|
// invoke Runtime.Shutdown during process exit.
|
||||||
func New(ctx context.Context, cfg Config) (*Runtime, error) {
|
func New(ctx context.Context, cfg Config) (*Runtime, error) {
|
||||||
return pkgtel.New(ctx, cfg)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// TraceFieldsFromContext returns zap fields identifying the active span, or nil
|
// TraceFieldsFromContext returns zap fields identifying the active span, or nil
|
||||||
// when ctx carries no valid span context. Collocated here so callers (the
|
// when ctx carries no valid span context. Collocated here so callers do not
|
||||||
// request-timing middleware and the access log) do not import the OpenTelemetry
|
// import the OpenTelemetry API directly.
|
||||||
// API directly.
|
|
||||||
func TraceFieldsFromContext(ctx context.Context) []zap.Field {
|
func TraceFieldsFromContext(ctx context.Context) []zap.Field {
|
||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -66,3 +166,39 @@ func TraceFieldsFromContext(ctx context.Context) []zap.Field {
|
|||||||
zap.String("otel_span_id", sc.SpanID().String()),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,15 +12,9 @@ func TestConfigValidate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
otlp := DefaultConfig()
|
otlp := DefaultConfig()
|
||||||
otlp.TracesExporter = ExporterOTLP
|
otlp.TracesExporter = "otlp"
|
||||||
if err := otlp.Validate(); err != nil {
|
if err := otlp.Validate(); err == nil {
|
||||||
t.Errorf("otlp exporter must be accepted: %v", err)
|
t.Error("otlp exporter must be rejected in the MVP set")
|
||||||
}
|
|
||||||
|
|
||||||
bad := DefaultConfig()
|
|
||||||
bad.MetricsExporter = "prometheus"
|
|
||||||
if err := bad.Validate(); err == nil {
|
|
||||||
t.Error("unsupported exporter must be rejected")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
noName := DefaultConfig()
|
noName := DefaultConfig()
|
||||||
|
|||||||
+44
-127
@@ -14,9 +14,8 @@ Three executables plus per-platform side-services:
|
|||||||
- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
|
- **`gateway`** — the only public ingress (module `scrabble/gateway`). Performs
|
||||||
anti-abuse (rate limiting), authenticates the player against the originating
|
anti-abuse (rate limiting), authenticates the player against the originating
|
||||||
platform (or an email/guest session), resolves the internal `user_id`, and
|
platform (or an email/guest session), resolves the internal `user_id`, and
|
||||||
forwards authenticated traffic to `backend` with an `X-User-ID` header. Serves the
|
forwards authenticated traffic to `backend` with an `X-User-ID` header. Hosts an
|
||||||
backend's admin console at `/_gm` on its public listener behind HTTP Basic Auth.
|
admin surface behind HTTP Basic Auth. Bridges live events from `backend` to the
|
||||||
Bridges live events from `backend` to the
|
|
||||||
client. The shared wire contracts (the push proto and the FlatBuffers edge
|
client. The shared wire contracts (the push proto and the FlatBuffers edge
|
||||||
payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`.
|
payloads) live in `scrabble/pkg`, imported by both `gateway` and `backend`.
|
||||||
- **`backend`** — internal-only service that owns every domain concern:
|
- **`backend`** — internal-only service that owns every domain concern:
|
||||||
@@ -45,12 +44,10 @@ Three executables plus per-platform side-services:
|
|||||||
mode). The visual/interaction design system is documented in
|
mode). The visual/interaction design system is documented in
|
||||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||||
- **`platform/telegram`** — the Telegram side-service (the "connector", module
|
- **`platform/telegram`** — the Telegram side-service (the "connector", module
|
||||||
`scrabble/platform/telegram`). It is the only component holding the bot tokens — **one
|
`scrabble/platform/telegram`). It is the only component holding the bot token: it
|
||||||
bot per service language** (`en`/`ru`), each its own token + game channel, the same
|
runs the Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves
|
||||||
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
|
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
|
and out-of-app push) and `backend` (admin messaging — Stage 10) call over the
|
||||||
trusted internal network. Its generic delivery methods are **platform-agnostic**
|
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
|
(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
|
initData validation is Telegram-specific. It runs in its own container, egressing to
|
||||||
@@ -65,7 +62,7 @@ flowchart LR
|
|||||||
Backend -- pgx --> Postgres[(Postgres)]
|
Backend -- pgx --> Postgres[(Postgres)]
|
||||||
Backend -. embeds .- Solver[[scrabble-solver library]]
|
Backend -. embeds .- Solver[[scrabble-solver library]]
|
||||||
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector]
|
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector]
|
||||||
Backend -. operator broadcasts (gRPC) .-> Telegram
|
Backend -. admin gRPC, Stage 10 .-> Telegram
|
||||||
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram))
|
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram))
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,18 +86,6 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||||
surface as Connect error codes.
|
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
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` for authenticated requests; `backend` never re-derives identity
|
`X-User-ID` for authenticated requests; `backend` never re-derives identity
|
||||||
from the body.
|
from the body.
|
||||||
@@ -121,29 +106,13 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
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`)
|
Telegram contact seeds the new account's language (from the launch `language_code`)
|
||||||
and display name (§4).
|
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
|
- 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).
|
storage is optional and may be unavailable; losing it means re-login).
|
||||||
- The gateway caches `session → user_id` and injects `X-User-ID`. Session
|
- 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
|
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
|
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
|
resolution, and treats sessions as **revoke-only** — they have no TTL and live
|
||||||
until explicitly revoked (`status` → `revoked`). A revoke can target one token or,
|
until explicitly revoked (`status` → `revoked`).
|
||||||
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
|
- **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
|
a durable `accounts` row flagged `is_guest` and carrying **no identity** — the
|
||||||
row is a technical necessity (the `sessions` and `game_players` foreign keys
|
row is a technical necessity (the `sessions` and `game_players` foreign keys
|
||||||
@@ -164,33 +133,17 @@ 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
|
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
|
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
|
development log mailer when none is configured) and, once verified, attaches a
|
||||||
confirmed email identity. Accounts and identities use application-generated
|
confirmed email identity. An email already confirmed by **another** account is
|
||||||
**UUIDv7** primary keys. A service flag `paid_account` (lifetime one-time
|
refused — adopting it would be a merge, which Stage 11 owns. Accounts and
|
||||||
payment; no purchase flow yet) is carried on the account and ORed on a merge.
|
identities use application-generated **UUIDv7** primary keys.
|
||||||
- **Linking** (Stage 11) is initiated from an authenticated profile and proves
|
- **Linking** is initiated from an authenticated profile: choose a platform →
|
||||||
control of the identity before attaching it: **email** through the confirm-code
|
complete that platform's web-auth confirm → attach the identity to the
|
||||||
flow, **Telegram** through the web **Login Widget** (validated by the connector,
|
current account.
|
||||||
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
|
- **Merge**: if the identity being linked already has its own account with
|
||||||
passes the trusted `external_id` to the backend, as for `auth.telegram`). The
|
history, the two accounts are **merged into the current one (A is primary)**:
|
||||||
request step **always** sends/accepts the proof (no pre-send "already taken"
|
statistics are summed, games and friends are transferred, duplicates are
|
||||||
signal, so a probe cannot enumerate registered addresses); a required **merge**
|
de-duplicated, the secondary account is retired. High blast-radius; an
|
||||||
is revealed **only after** the proof is verified and is performed behind an
|
isolated, well-tested stage.
|
||||||
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`)
|
## 5. Game engine integration (`scrabble-solver`)
|
||||||
|
|
||||||
@@ -213,16 +166,11 @@ Key points:
|
|||||||
word-check tool through `Registry.Lookup`.
|
word-check tool through `Registry.Lookup`.
|
||||||
- **Dictionary versioning — pin per game.** A game records the `dict_version` it
|
- **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
|
started on and finishes on that version; new games use the latest. Multiple
|
||||||
versions may be resident at once. The boot version loads from the flat
|
versions may be resident at once. An admin reload *(planned, Stage 10)*
|
||||||
`BACKEND_DICT_DIR`; the admin console **hot-reloads** a new version from a
|
registers a new version through `Registry.Load`; delivery is the DAWG file in
|
||||||
per-version subdirectory `BACKEND_DICT_DIR/<version>/` through
|
the image / a volume mounted at the dictionary directory. (A future split of
|
||||||
`Registry.LoadAvailable` (only the variants whose DAWG is present there), and a
|
the solver into engine + dictionary generator with versioned artifacts is
|
||||||
restart re-loads every resident version via `engine.OpenWithVersions` (the flat
|
recorded in [`../PLAN.md`](../PLAN.md) TODO-2.)
|
||||||
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),
|
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
|
||||||
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
|
||||||
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
|
||||||
@@ -283,11 +231,8 @@ Key points:
|
|||||||
"no options" rather than "no hints left".
|
"no options" rather than "no hints left".
|
||||||
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
|
- **Word-check tool**: unlimited dictionary lookups against the game's pinned
|
||||||
dictionary; each result offers a **complaint** (complainant, game, variant,
|
dictionary; each result offers a **complaint** (complainant, game, variant,
|
||||||
dict_version, word, the disputed result, an optional note) that lands in the admin
|
dict_version, word, the disputed result, an optional note) that lands in an
|
||||||
review queue. An operator resolves it (`open → resolved`) with a **disposition** —
|
admin review queue *(admin side planned, Stage 10)*.
|
||||||
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
|
## 7. Robot opponent
|
||||||
|
|
||||||
@@ -384,9 +329,7 @@ requires (there is no DM surface; chat is per-game).
|
|||||||
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
|
- 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
|
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; Stage 9's
|
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;
|
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)`;
|
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
|
||||||
Stage 5's migration `00004` admits the `robot` kind),
|
Stage 5's migration `00004` admits the `robot` kind),
|
||||||
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
|
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
|
||||||
@@ -436,11 +379,6 @@ 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
|
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.
|
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
|
## 10. Notifications
|
||||||
|
|
||||||
Two channels: the **in-app live stream** (delivered from Stage 6) and
|
Two channels: the **in-app live stream** (delivered from Stage 6) and
|
||||||
@@ -463,16 +401,12 @@ open and on focus as well as re-polling on the `notify` event — covering a pus
|
|||||||
missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
|
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
|
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
|
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
|
`external_id`, language, and the `notifications_in_app_only` flag) and asks the
|
||||||
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
|
**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
|
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
|
notifications to the app, so the two channels never duplicate. The out-of-app set is
|
||||||
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;
|
your-turn, nudge, match-found and the invitation / friend-request notify sub-kinds;
|
||||||
the connector renders the message and skips the rest. Operator broadcasts
|
the connector renders the message and skips the rest. Session-revocation events and
|
||||||
(`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).
|
cursor-based stream resume stay deferred (single-instance MVP).
|
||||||
|
|
||||||
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
|
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
|
||||||
@@ -482,27 +416,14 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
|||||||
## 11. Observability
|
## 11. Observability
|
||||||
|
|
||||||
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
|
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
|
||||||
meter providers are wired in **all three services** (backend, gateway, the
|
meter providers are wired (Stage 1), env-gated by
|
||||||
Telegram connector) through a shared `pkg/telemetry` bootstrap, env-gated per
|
`BACKEND_OTEL_{TRACES,METRICS}_EXPORTER` with a default of `none` (so no
|
||||||
service by `{BACKEND,GATEWAY,TELEGRAM}_OTEL_{TRACES,METRICS}_EXPORTER` with a
|
collector is required locally or in CI); `stdout` is available for debugging
|
||||||
default of `none` (so no collector is required locally or in CI). `stdout` is
|
and the Postgres pool is instrumented with otelsql. OTLP export, a Prometheus
|
||||||
available for debugging; **`otlp`** (gRPC, endpoint from the standard
|
pull endpoint, and dashboards arrive with the first real workload.
|
||||||
`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
|
- 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
|
carries method, route, status, latency and the active trace id). A
|
||||||
client-measured RTT piggybacked on the next request is a later enhancement.
|
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
|
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
||||||
database answers a bounded ping and the session cache is warmed).
|
database answers a bounded ping and the session cache is warmed).
|
||||||
- The backend serves a **second listener** — a gRPC server
|
- The backend serves a **second listener** — a gRPC server
|
||||||
@@ -518,7 +439,7 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
|||||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||||
| 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 |
|
| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints |
|
||||||
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
| 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
|
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
|
||||||
@@ -537,15 +458,14 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
|
|||||||
## 13. Deployment (informational)
|
## 13. Deployment (informational)
|
||||||
|
|
||||||
Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini
|
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;
|
App under `/telegram/`** (the gateway serves the static UI build; outside Telegram
|
||||||
outside Telegram that path redirects to the root), the gateway public surface and the **admin console
|
that path redirects to the root), the gateway public surface and the admin surface
|
||||||
at `/_gm`** (backend-rendered, Basic-Auth at the gateway) share one host that
|
share one host that terminates TLS. The **Telegram connector** runs as a separate
|
||||||
terminates TLS. The **Telegram connector** runs as a separate
|
|
||||||
container with **no public ingress** — it long-polls Telegram and egresses through a
|
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
|
VPN sidecar, answering only internal gRPC. MVP runs one `gateway`, one `backend`, one
|
||||||
Postgres, plus the connector. The connector's Docker/compose ships now
|
Postgres, plus the connector. The connector's Docker/compose ships now
|
||||||
(`platform/telegram/deploy`, mirroring `../15-puzzle`); the gateway's static UI serving
|
(`platform/telegram/deploy`, mirroring `../15-puzzle`); the full multi-service deploy
|
||||||
and the full multi-service deploy land in Stage 15.
|
is Stage 12.
|
||||||
|
|
||||||
## 14. CI & branches
|
## 14. CI & branches
|
||||||
|
|
||||||
@@ -557,12 +477,9 @@ and the full multi-service deploy land in Stage 15.
|
|||||||
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
|
`integration` build tag (testcontainers `postgres:17-alpine`, Ryuk disabled,
|
||||||
serial). Further workflows (ui-test, deploy) are added with the components they
|
serial). Further workflows (ui-test, deploy) are added with the components they
|
||||||
cover.
|
cover.
|
||||||
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
- Since Stage 2 both Go workflows clone the public `scrabble-solver` sibling
|
||||||
(`gitea.iliadenisov.ru/developer/scrabble-solver`, pinned in `backend/go.mod`); both Go
|
(master HEAD, no credentials) into `../scrabble-solver` before building, so the
|
||||||
workflows set `GOPRIVATE=gitea.iliadenisov.ru/*` so go fetches it directly from this Gitea
|
`go.work` `replace` resolves; the engine tests read the committed DAWGs from
|
||||||
(no public proxy/checksum DB, no sibling clone). The dictionaries ship as a **release
|
that checkout via `BACKEND_DICT_DIR`.
|
||||||
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
|
- After any push, the run is watched to green before a stage is declared done
|
||||||
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
|
(`python3 ~/.claude/bin/gitea-ci-watch.py`).
|
||||||
|
|||||||
+19
-42
@@ -22,45 +22,30 @@ 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
|
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.
|
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||||
|
|
||||||
### Identity & sessions *(Stage 1 / 6 / 9 / 15)*
|
### Identity & sessions *(Stage 1 / 6 / 9)*
|
||||||
A player arrives from a platform (Telegram first), via email login, or as an
|
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
|
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
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
|
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
|
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
|
language from the Telegram client. Guests are session-only with restricted features
|
||||||
languages** it offers (a set of en/ru, at least one), which gate the New Game variant
|
(auto-match only; no friends, stats or history). While the app is open the client
|
||||||
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,
|
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
|
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)
|
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
|
arrive as a **Telegram notification** instead — unless the player keeps notifications
|
||||||
in the app only (a profile setting, **on by default**).
|
in the app only (a profile setting, **on by default**).
|
||||||
|
|
||||||
### Accounts, linking & merge *(Stage 1 / 11)*
|
### Accounts, linking & merge *(Stage 1 / 10)*
|
||||||
First platform contact auto-provisions a durable account. From the profile a player
|
First platform contact auto-provisions a durable account. From the profile a
|
||||||
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
|
player links additional platform identities or an email via a confirm flow;
|
||||||
who links their first identity becomes a durable account. The "already taken" status
|
linking an identity that already has history merges it into the current
|
||||||
of an identity is never revealed before the code/sign-in is verified. If the linked
|
account (stats summed, games/friends transferred).
|
||||||
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 / 15)*
|
### Lobby & matchmaking *(Stage 4)*
|
||||||
Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are
|
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a
|
||||||
limited to the languages the player's sign-in service supports (English → English;
|
per-variant pool and is paired with the next waiting human; after 10 s with no
|
||||||
Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is
|
human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
|
||||||
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 (2–4) are
|
|
||||||
formed by inviting players from the friend list (an invitation, like a friend code,
|
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
|
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
|
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||||
@@ -110,9 +95,11 @@ nudge is part of the game chat); the out-of-app push is delivered via the platfo
|
|||||||
### Profile & settings *(Stage 4 / 8)*
|
### Profile & settings *(Stage 4 / 8)*
|
||||||
Edit the display name (letters joined by single space / "." / "_" separators, up to
|
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
|
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. Linking
|
10-minute grid, at most 12 hours, wrapping midnight) and the block toggles, and bind
|
||||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
an email by confirm-code: the backend emails a short code that,
|
||||||
merge" (Stage 11).
|
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.
|
||||||
|
|
||||||
### History & statistics *(Stage 3 / 8)*
|
### History & statistics *(Stage 3 / 8)*
|
||||||
Finished games are archived in a dictionary-independent form and exportable to
|
Finished games are archived in a dictionary-independent form and exportable to
|
||||||
@@ -123,15 +110,5 @@ 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).
|
best play, which already includes every word it formed plus the all-tiles bonus).
|
||||||
|
|
||||||
### Administration *(Stage 10)*
|
### Administration *(Stage 10)*
|
||||||
Operators reach a server-rendered admin console at `${DOMAIN}/_gm` — the backend
|
Admin (Basic Auth at the gateway) reviews word complaints, manages dictionary
|
||||||
renders it; the gateway gates it with HTTP Basic Auth on its public listener and
|
versions, and inspects users/games.
|
||||||
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.
|
|
||||||
|
|||||||
+16
-38
@@ -23,44 +23,28 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||||
и ограничивает частоту повторов.
|
и ограничивает частоту повторов.
|
||||||
|
|
||||||
### Личность и сессии *(Stage 1 / 6 / 9 / 15)*
|
### Личность и сессии *(Stage 1 / 6 / 9)*
|
||||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
||||||
языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он
|
языку Telegram-клиента. Гость — только сессия, с урезанными функциями (только
|
||||||
предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в
|
авто-подбор; без друзей, статистики и истории). Пока приложение открыто, клиент
|
||||||
лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок
|
|
||||||
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки и
|
|
||||||
является тем ботом, от которого приходят его внеприложенческие уведомления. Гость — только сессия, с урезанными функциями (только
|
|
||||||
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
|
|
||||||
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
|
|
||||||
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
|
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
|
||||||
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
|
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
|
||||||
события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят
|
события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят
|
||||||
вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления
|
вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления
|
||||||
только в приложении (настройка профиля, **включена по умолчанию**).
|
только в приложении (настройка профиля, **включена по умолчанию**).
|
||||||
|
|
||||||
### Аккаунты, привязка и слияние *(Stage 1 / 11)*
|
### Аккаунты, привязка и слияние *(Stage 1 / 10)*
|
||||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||||
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
|
привязывает другие платформенные личности или email через confirm-поток;
|
||||||
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
|
привязка личности, у которой уже есть история, сливает её в текущий аккаунт
|
||||||
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
|
(статистика суммируется, игры/друзья переносятся).
|
||||||
принадлежит другому аккаунту, игроку показывают явное **необратимое**
|
|
||||||
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
|
|
||||||
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
|
|
||||||
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
|
|
||||||
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
|
|
||||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
|
||||||
|
|
||||||
### Лобби и подбор *(Stage 4 / 15)*
|
### Лобби и подбор *(Stage 4)*
|
||||||
Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра**
|
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
|
||||||
ограничены языками, которые поддерживает сервис входа игрока (английский → English;
|
|
||||||
русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не
|
|
||||||
ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и
|
|
||||||
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
|
|
||||||
любом языке. Авто-подбор (всегда 2 игрока)
|
|
||||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||||
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
|
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
|
||||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||||
@@ -114,8 +98,11 @@ push доставляется через платформу.
|
|||||||
Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
|
Редактирование отображаемого имени (буквы, разделённые одиночными пробелом / «.» /
|
||||||
«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
|
«_», до 32 символов), таймзоны (выбор смещения от UTC), суточного окна отсутствия
|
||||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
|
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и
|
||||||
переключателей блокировок. Привязка email и Telegram, а также слияние аккаунтов
|
переключателей блокировок, а также привязка email по confirm-коду: backend шлёт на
|
||||||
вынесены в раздел «Аккаунты, привязка и слияние» (Stage 11).
|
почту короткий код, и после ввода email
|
||||||
|
привязывается к аккаунту (email, уже подтверждённый другим аккаунтом, занять
|
||||||
|
нельзя — это слияние, отдельный этап). Привязанные платформенные аккаунты и
|
||||||
|
слияние появятся в Stage 11.
|
||||||
|
|
||||||
### История и статистика *(Stage 3 / 8)*
|
### История и статистика *(Stage 3 / 8)*
|
||||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||||
@@ -126,14 +113,5 @@ push доставляется через платформу.
|
|||||||
ход, уже включающий все образованные им слова и бонус за все фишки).
|
ход, уже включающий все образованные им слова и бонус за все фишки).
|
||||||
|
|
||||||
### Администрирование *(Stage 10)*
|
### Администрирование *(Stage 10)*
|
||||||
Оператор открывает серверную админ-консоль по адресу `${DOMAIN}/_gm` — её рендерит
|
Админ (Basic Auth на gateway) разбирает жалобы на слова, управляет версиями
|
||||||
backend; gateway закрывает её HTTP Basic Auth на публичном порту и проксирует
|
словаря, смотрит пользователей/игры.
|
||||||
один-в-один. В консоли можно смотреть **пользователей** (профиль, статистика,
|
|
||||||
identity, их игры) и **игры** (сводка + места), разбирать **очередь жалоб на слова** —
|
|
||||||
закрывая каждую как reject / accept-add / accept-remove — и управлять **словарём**:
|
|
||||||
резидентные версии по вариантам, **горячая перезагрузка** новой версии из
|
|
||||||
`BACKEND_DICT_DIR/<version>/` и **список ожидающих правок**, выведенный из принятых
|
|
||||||
жалоб (он питает офлайн-пересборку и отмечается применённым после перезагрузки). Если
|
|
||||||
подключён Telegram-коннектор, оператор также может **написать пользователю** (по его
|
|
||||||
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
|
|
||||||
защищены проверкой same-origin; личность оператора не отслеживается.
|
|
||||||
|
|||||||
@@ -82,27 +82,6 @@ tests or touching CI.
|
|||||||
in `inttest`. Stage 8 adds gateway transcode round-trips for the new social/account
|
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,
|
operations (friends list, friend code issue/redeem, invitation create, stats, GCG,
|
||||||
the profile-update away round-trip) and a `notify`-event constructor round-trip.
|
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
|
## Principles
|
||||||
|
|
||||||
|
|||||||
+10
-17
@@ -5,9 +5,9 @@ terminates the client's **Connect-RPC + FlatBuffers** traffic over HTTP/2
|
|||||||
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
cleartext (`h2c`), authenticates the originating credential, mints/resolves a
|
||||||
thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
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
|
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||||
client's in-app live channel. It also serves the backend's admin console at `/_gm`
|
client's in-app live channel. It also fronts the backend admin API behind HTTP
|
||||||
on its public listener behind HTTP Basic-Auth. See
|
Basic-Auth. See [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10,
|
||||||
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §2, §3, §10, §12.
|
§12.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ internal/connector/ # gRPC client to the Telegram connector (initData valida
|
|||||||
internal/push/ # live-event fan-out hub (per-user client streams)
|
internal/push/ # live-event fan-out hub (per-user client streams)
|
||||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||||
internal/connectsrv/ # the Connect Gateway service over h2c
|
internal/connectsrv/ # the Connect Gateway service over h2c
|
||||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
internal/admin/ # Basic-Auth reverse proxy to the backend admin API
|
||||||
```
|
```
|
||||||
|
|
||||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||||
@@ -51,31 +51,24 @@ The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
|
|||||||
added the play-loop ops; **Stage 8** added the social/account/history ops —
|
added the play-loop ops; **Stage 8** added the social/account/history ops —
|
||||||
`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
|
`friends.*` (list/incoming/request/respond/cancel/unfriend/code.issue/code.redeem),
|
||||||
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
`blocks.*`, `invitation.*` (list/create/accept/decline/cancel), `profile.update`,
|
||||||
`stats.get`, `game.gcg`, and the `notify` live event — all via the identical
|
`email.bind.*`, `stats.get`, `game.gcg`, and the `notify` live event — all via the
|
||||||
transcode pattern (`transcode_social.go`). **Stage 11** added account linking & merge
|
identical transcode pattern (`transcode_social.go`).
|
||||||
— `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
|
## Configuration
|
||||||
|
|
||||||
| Variable | Default | Notes |
|
| Variable | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener (also serves the admin console at `/_gm`) |
|
| `GATEWAY_HTTP_ADDR` | `:8081` | public Connect/h2c listener |
|
||||||
|
| `GATEWAY_ADMIN_ADDR` | `:8082` | admin proxy listener (enabled only with creds) |
|
||||||
| `GATEWAY_LOG_LEVEL` | `info` | zap level |
|
| `GATEWAY_LOG_LEVEL` | `info` | zap level |
|
||||||
| `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL |
|
| `GATEWAY_BACKEND_HTTP_URL` | `http://localhost:8080` | backend REST base URL |
|
||||||
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
|
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
|
||||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin proxy |
|
||||||
| `GATEWAY_CONNECTOR_ADDR` | unset | Telegram connector gRPC address (enables initData validation + out-of-app push) |
|
| `GATEWAY_TELEGRAM_BOT_TOKEN` | unset | enable the Telegram auth path |
|
||||||
| `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_TTL` | `10m` | cached session lifetime |
|
||||||
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
|
||||||
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `15s` | live-stream keep-alive |
|
| `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
|
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.
|
120/min·user (burst 40), admin 60/min·IP (burst 20), email-code 5/10 min·IP.
|
||||||
|
|||||||
+22
-45
@@ -2,8 +2,8 @@
|
|||||||
// the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform /
|
// the client's Connect-RPC/FlatBuffers traffic over h2c, validates platform /
|
||||||
// email / guest credentials and mints opaque sessions, rate-limits, injects
|
// 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
|
// 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 serves the
|
// gRPC push stream to each client's in-app live channel. It also fronts the
|
||||||
// backend's admin console at /_gm on the public listener behind HTTP Basic-Auth.
|
// backend admin API behind HTTP Basic-Auth.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -26,14 +26,11 @@ import (
|
|||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
"scrabble/gateway/internal/session"
|
"scrabble/gateway/internal/session"
|
||||||
"scrabble/gateway/internal/transcode"
|
"scrabble/gateway/internal/transcode"
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// shutdownTimeout bounds the graceful HTTP shutdown.
|
// shutdownTimeout bounds the graceful HTTP shutdown.
|
||||||
shutdownTimeout = 10 * time.Second
|
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
|
// pushReconnectDelay is the pause before re-subscribing to the backend push
|
||||||
// stream after it ends.
|
// stream after it ends.
|
||||||
pushReconnectDelay = 2 * time.Second
|
pushReconnectDelay = 2 * time.Second
|
||||||
@@ -60,27 +57,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// run wires the gateway dependencies and serves the public listener (which also
|
// run wires the gateway dependencies and serves the public (and optional admin)
|
||||||
// fronts the admin console at /_gm) until the context is cancelled.
|
// listeners until the context is cancelled.
|
||||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
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)
|
backend, err := backendclient.New(cfg.BackendHTTPURL, cfg.BackendGRPCAddr, cfg.BackendTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -104,30 +86,15 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
logger.Warn("telegram disabled (GATEWAY_CONNECTOR_ADDR unset)")
|
logger.Warn("telegram disabled (GATEWAY_CONNECTOR_ADDR unset)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin console (backend /_gm) is fronted on the public listener behind
|
registry := transcode.NewRegistry(backend, validator)
|
||||||
// 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{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
Limiter: limiter,
|
Limiter: limiter,
|
||||||
Hub: hub,
|
Hub: hub,
|
||||||
RateLimit: cfg.RateLimit,
|
RateLimit: cfg.RateLimit,
|
||||||
Heartbeat: cfg.PushHeartbeatInterval,
|
Heartbeat: cfg.PushHeartbeatInterval,
|
||||||
Logger: logger,
|
Logger: logger,
|
||||||
AdminProxy: adminProxy,
|
|
||||||
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||||
@@ -137,6 +104,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
|
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
|
||||||
servers := []*namedServer{{name: "public", srv: public}}
|
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",
|
logger.Info("gateway starting",
|
||||||
zap.String("http_addr", cfg.HTTPAddr),
|
zap.String("http_addr", cfg.HTTPAddr),
|
||||||
zap.String("backend_http", cfg.BackendHTTPURL),
|
zap.String("backend_http", cfg.BackendHTTPURL),
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/text v0.36.0 // indirect
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
// Package admin is the gateway's admin edge: HTTP Basic-Auth in front of a reverse
|
// Package admin is the gateway's admin surface: HTTP Basic-Auth in front of a
|
||||||
// proxy that forwards the operator's browser to the backend's server-rendered admin
|
// reverse proxy to the backend admin API (docs/ARCHITECTURE.md §12). The gateway
|
||||||
// console under /_gm. The proxy is mounted at /_gm/ on the gateway's public listener
|
// validates the operator credential and forwards authenticated requests to
|
||||||
// (below the h2c wrap, see internal/connectsrv) and forwards verbatim — an inbound
|
// backend /api/v1/admin/*; the backend trusts the gateway on this segment. The
|
||||||
// /_gm/<rest> reaches <backendURL>/_gm/<rest>, preserving the inbound Host so the
|
// admin API itself is filled in Stage 10.
|
||||||
// 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
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,14 +11,17 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"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
|
// NewProxy returns a handler that checks Basic-Auth against user/password and
|
||||||
// reverse-proxies the request verbatim to the backend: the inbound path is
|
// reverse-proxies the request to the backend admin API, mapping an inbound
|
||||||
// preserved, so /_gm/<rest> reaches <backendURL>/_gm/<rest>. It is mounted at /_gm/
|
// /admin/<rest> path to <backendURL>/api/v1/admin/<rest>.
|
||||||
// on the gateway's public listener.
|
|
||||||
func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) {
|
func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler, error) {
|
||||||
target, err := url.Parse(backendURL)
|
target, err := url.Parse(backendURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,8 +32,10 @@ func NewProxy(backendURL, user, password string, log *zap.Logger) (http.Handler,
|
|||||||
}
|
}
|
||||||
proxy := &httputil.ReverseProxy{
|
proxy := &httputil.ReverseProxy{
|
||||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||||
pr.SetURL(target) // backend scheme+host; the inbound /_gm path is preserved
|
pr.SetURL(target)
|
||||||
pr.Out.Host = pr.In.Host // keep the public Host for the backend same-origin check
|
rel := strings.TrimPrefix(pr.In.URL.Path, "/admin")
|
||||||
|
pr.Out.URL.Path = backendAdminPrefix + rel
|
||||||
|
pr.Out.Host = pr.In.Host
|
||||||
},
|
},
|
||||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
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))
|
log.Warn("admin proxy upstream error", zap.String("path", r.URL.Path), zap.Error(err))
|
||||||
|
|||||||
@@ -9,28 +9,27 @@ import (
|
|||||||
"scrabble/gateway/internal/admin"
|
"scrabble/gateway/internal/admin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newAdmin fronts a fake backend with the admin proxy. The fake backend records the
|
func newAdmin(t *testing.T) (*httptest.Server, func()) {
|
||||||
// path it receives so a test can assert the proxy forwards /_gm verbatim.
|
|
||||||
func newAdmin(t *testing.T) (front *httptest.Server, gotPath *string, cleanup func()) {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var path string
|
|
||||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
path = r.URL.Path
|
if r.URL.Path != "/api/v1/admin/ping" {
|
||||||
_, _ = w.Write([]byte("console"))
|
t.Errorf("backend path = %q, want /api/v1/admin/ping", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("pong"))
|
||||||
}))
|
}))
|
||||||
proxy, err := admin.NewProxy(backend.URL, "ops", "secret", nil)
|
proxy, err := admin.NewProxy(backend.URL, "ops", "secret", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new proxy: %v", err)
|
t.Fatalf("new proxy: %v", err)
|
||||||
}
|
}
|
||||||
front = httptest.NewServer(proxy)
|
front := httptest.NewServer(proxy)
|
||||||
return front, &path, func() { front.Close(); backend.Close() }
|
return front, func() { front.Close(); backend.Close() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRejectsMissingCredentials(t *testing.T) {
|
func TestAdminRejectsMissingCredentials(t *testing.T) {
|
||||||
front, _, cleanup := newAdmin(t)
|
front, cleanup := newAdmin(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
resp, err := http.Get(front.URL + "/_gm/")
|
resp, err := http.Get(front.URL + "/admin/ping")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -38,16 +37,13 @@ func TestAdminRejectsMissingCredentials(t *testing.T) {
|
|||||||
if resp.StatusCode != http.StatusUnauthorized {
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
t.Fatalf("status = %d, want 401", resp.StatusCode)
|
||||||
}
|
}
|
||||||
if resp.Header.Get("WWW-Authenticate") == "" {
|
|
||||||
t.Error("missing WWW-Authenticate challenge")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminProxiesVerbatimWithCredentials(t *testing.T) {
|
func TestAdminProxiesWithCredentials(t *testing.T) {
|
||||||
front, gotPath, cleanup := newAdmin(t)
|
front, cleanup := newAdmin(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, front.URL+"/_gm/complaints", nil)
|
req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil)
|
||||||
req.SetBasicAuth("ops", "secret")
|
req.SetBasicAuth("ops", "secret")
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,19 +51,16 @@ func TestAdminProxiesVerbatimWithCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
if resp.StatusCode != http.StatusOK || string(body) != "console" {
|
if resp.StatusCode != http.StatusOK || string(body) != "pong" {
|
||||||
t.Fatalf("status = %d body = %q, want 200 console", resp.StatusCode, body)
|
t.Fatalf("status = %d body = %q, want 200 pong", resp.StatusCode, body)
|
||||||
}
|
|
||||||
if *gotPath != "/_gm/complaints" {
|
|
||||||
t.Errorf("backend path = %q, want /_gm/complaints (verbatim)", *gotPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminRejectsWrongPassword(t *testing.T) {
|
func TestAdminRejectsWrongPassword(t *testing.T) {
|
||||||
front, _, cleanup := newAdmin(t)
|
front, cleanup := newAdmin(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
||||||
req, _ := http.NewRequest(http.MethodGet, front.URL+"/_gm/", nil)
|
req, _ := http.NewRequest(http.MethodGet, front.URL+"/admin/ping", nil)
|
||||||
req.SetBasicAuth("ops", "wrong")
|
req.SetBasicAuth("ops", "wrong")
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// The structs below mirror the backend's JSON DTOs (backend/internal/server
|
// The structs below mirror the backend's JSON DTOs (backend/internal/server
|
||||||
@@ -34,22 +33,7 @@ type ProfileResp struct {
|
|||||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkResultResp is the result of an account link/merge step (Stage 11). Status is
|
// TileJSON is one placed tile, used in both play requests and move responses.
|
||||||
// "linked", "merge_required" (the secondary_* fields summarise the other account) or
|
|
||||||
// "merged". Token is a switched-session token (a guest initiator's durable
|
|
||||||
// counterpart won); Profile is the surviving/active account's profile.
|
|
||||||
type LinkResultResp struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
SecondaryUserID string `json:"secondary_user_id"`
|
|
||||||
SecondaryName string `json:"secondary_display_name"`
|
|
||||||
SecondaryGames int `json:"secondary_games"`
|
|
||||||
SecondaryFriends int `json:"secondary_friends"`
|
|
||||||
Token string `json:"token"`
|
|
||||||
Profile *ProfileResp `json:"profile"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TileJSON is one tile in a decoded move response (history, move result, hint); its Letter
|
|
||||||
// is a concrete character (Stage 13 keeps the move journal in letters).
|
|
||||||
type TileJSON struct {
|
type TileJSON struct {
|
||||||
Row int `json:"row"`
|
Row int `json:"row"`
|
||||||
Col int `json:"col"`
|
Col int `json:"col"`
|
||||||
@@ -57,15 +41,6 @@ type TileJSON struct {
|
|||||||
Blank bool `json:"blank"`
|
Blank bool `json:"blank"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayTileJSON is one inbound tile to place, addressed by alphabet index (Stage 13). For a
|
|
||||||
// blank, Letter is the designated letter's index and Blank is true.
|
|
||||||
type PlayTileJSON struct {
|
|
||||||
Row int `json:"row"`
|
|
||||||
Col int `json:"col"`
|
|
||||||
Letter int `json:"letter"`
|
|
||||||
Blank bool `json:"blank"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveRecordResp is a decoded move.
|
// MoveRecordResp is a decoded move.
|
||||||
type MoveRecordResp struct {
|
type MoveRecordResp struct {
|
||||||
Player int `json:"player"`
|
Player int `json:"player"`
|
||||||
@@ -110,23 +85,13 @@ type MoveResultResp struct {
|
|||||||
Game GameResp `json:"game"`
|
Game GameResp `json:"game"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlphabetEntryJSON is one letter of a variant's alphabet (its index, concrete letter and
|
// StateResp is a player's view of a game.
|
||||||
// tile value), present in StateResp only when the client requested it (Stage 13).
|
|
||||||
type AlphabetEntryJSON struct {
|
|
||||||
Index int `json:"index"`
|
|
||||||
Letter string `json:"letter"`
|
|
||||||
Value int `json:"value"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StateResp is a player's view of a game. Rack carries wire alphabet indices (Stage 13);
|
|
||||||
// Alphabet is present only when the request asked for it.
|
|
||||||
type StateResp struct {
|
type StateResp struct {
|
||||||
Game GameResp `json:"game"`
|
Game GameResp `json:"game"`
|
||||||
Seat int `json:"seat"`
|
Seat int `json:"seat"`
|
||||||
Rack []int `json:"rack"`
|
Rack []string `json:"rack"`
|
||||||
BagLen int `json:"bag_len"`
|
BagLen int `json:"bag_len"`
|
||||||
HintsRemaining int `json:"hints_remaining"`
|
HintsRemaining int `json:"hints_remaining"`
|
||||||
Alphabet []AlphabetEntryJSON `json:"alphabet,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchResp reports an auto-match outcome.
|
// MatchResp reports an auto-match outcome.
|
||||||
@@ -146,18 +111,15 @@ type ChatResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
||||||
// brand-new account's display name and language from the validated launch fields and
|
// brand-new account's display name and language from the validated launch fields.
|
||||||
// recording the validating bot's serviceLanguage (which routes the account's later
|
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
|
||||||
// out-of-app push).
|
|
||||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, serviceLanguage string) (SessionResp, error) {
|
|
||||||
var out SessionResp
|
var out SessionResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"external_id": externalID,
|
"external_id": externalID,
|
||||||
"language_code": languageCode,
|
"language_code": languageCode,
|
||||||
"username": username,
|
"username": username,
|
||||||
"first_name": firstName,
|
"first_name": firstName,
|
||||||
"service_language": serviceLanguage,
|
|
||||||
}, &out)
|
}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -218,25 +180,18 @@ func (c *Client) Profile(ctx context.Context, userID string) (ProfileResp, error
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubmitPlay commits a placement on the player's turn. The tiles are addressed by alphabet
|
// SubmitPlay commits a placement on the player's turn.
|
||||||
// index (Stage 13).
|
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (MoveResultResp, error) {
|
||||||
func (c *Client) SubmitPlay(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (MoveResultResp, error) {
|
|
||||||
var out MoveResultResp
|
var out MoveResultResp
|
||||||
body := map[string]any{"dir": dir, "tiles": tiles}
|
body := map[string]any{"dir": dir, "tiles": tiles}
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/games/"+url.PathEscape(gameID)+"/play", userID, "", body, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GameState returns the player's view of a game. When includeAlphabet is set the backend
|
// GameState returns the player's view of a game.
|
||||||
// embeds the variant's alphabet table (Stage 13); the client asks for it on a per-variant
|
func (c *Client) GameState(ctx context.Context, userID, gameID string) (StateResp, error) {
|
||||||
// cache miss only.
|
|
||||||
func (c *Client) GameState(ctx context.Context, userID, gameID string, includeAlphabet bool) (StateResp, error) {
|
|
||||||
var out StateResp
|
var out StateResp
|
||||||
path := "/api/v1/user/games/" + url.PathEscape(gameID) + "/state"
|
err := c.do(ctx, http.MethodGet, "/api/v1/user/games/"+url.PathEscape(gameID)+"/state", userID, "", nil, &out)
|
||||||
if includeAlphabet {
|
|
||||||
path += "?include_alphabet=true"
|
|
||||||
}
|
|
||||||
err := c.do(ctx, http.MethodGet, path, userID, "", nil, &out)
|
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,9 +264,8 @@ func (c *Client) Pass(ctx context.Context, userID, gameID string) (MoveResultRes
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange swaps the chosen rack tiles back into the bag. Tiles are wire alphabet indices
|
// Exchange swaps the chosen rack tiles back into the bag.
|
||||||
// (Stage 13; a blank is engine.BlankIndex).
|
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []string) (MoveResultResp, error) {
|
||||||
func (c *Client) Exchange(ctx context.Context, userID, gameID string, tiles []int) (MoveResultResp, error) {
|
|
||||||
var out MoveResultResp
|
var out MoveResultResp
|
||||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/exchange"), userID, "",
|
||||||
map[string]any{"tiles": tiles}, &out)
|
map[string]any{"tiles": tiles}, &out)
|
||||||
@@ -332,24 +286,18 @@ func (c *Client) Hint(ctx context.Context, userID, gameID string) (HintResultRes
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Evaluate previews a tentative play's legality and score. The tiles are addressed by
|
// Evaluate previews a tentative play's legality and score.
|
||||||
// alphabet index (Stage 13).
|
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []TileJSON) (EvalResultResp, error) {
|
||||||
func (c *Client) Evaluate(ctx context.Context, userID, gameID, dir string, tiles []PlayTileJSON) (EvalResultResp, error) {
|
|
||||||
var out EvalResultResp
|
var out EvalResultResp
|
||||||
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
err := c.do(ctx, http.MethodPost, c.gamePath(gameID, "/evaluate"), userID, "",
|
||||||
map[string]any{"dir": dir, "tiles": tiles}, &out)
|
map[string]any{"dir": dir, "tiles": tiles}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
|
// CheckWord looks a word up in the game's pinned dictionary.
|
||||||
// repeated ?idx= alphabet indices (Stage 13); the backend echoes the decoded concrete word.
|
func (c *Client) CheckWord(ctx context.Context, userID, gameID, word string) (WordCheckResp, error) {
|
||||||
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
|
|
||||||
var out WordCheckResp
|
var out WordCheckResp
|
||||||
q := url.Values{}
|
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?word="+url.QueryEscape(word), userID, "", nil, &out)
|
||||||
for _, x := range word {
|
|
||||||
q.Add("idx", strconv.Itoa(x))
|
|
||||||
}
|
|
||||||
err := c.do(ctx, http.MethodGet, c.gamePath(gameID, "/check_word")+"?"+q.Encode(), userID, "", nil, &out)
|
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,47 +228,20 @@ func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkEmailRequest asks the backend to mail a confirm-code for a link or merge.
|
// EmailBindRequest asks the backend to mail a confirm-code binding email.
|
||||||
func (c *Client) LinkEmailRequest(ctx context.Context, userID, email string) error {
|
func (c *Client) EmailBindRequest(ctx context.Context, userID, email string) error {
|
||||||
return c.do(ctx, http.MethodPost, "/api/v1/user/link/email/request", userID, "",
|
return c.do(ctx, http.MethodPost, "/api/v1/user/email/request", userID, "",
|
||||||
map[string]string{"email": email}, nil)
|
map[string]string{"email": email}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkEmailConfirm verifies the code and binds a free email or reports a required
|
// EmailBindConfirm verifies the code and binds the email, returning the profile.
|
||||||
// merge (Stage 11).
|
func (c *Client) EmailBindConfirm(ctx context.Context, userID, email, code string) (ProfileResp, error) {
|
||||||
func (c *Client) LinkEmailConfirm(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
|
var out ProfileResp
|
||||||
var out LinkResultResp
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/email/confirm", userID, "",
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/confirm", userID, "",
|
|
||||||
map[string]string{"email": email, "code": code}, &out)
|
map[string]string{"email": email, "code": code}, &out)
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkEmailMerge re-verifies the code and performs the merge (Stage 11).
|
|
||||||
func (c *Client) LinkEmailMerge(ctx context.Context, userID, email, code string) (LinkResultResp, error) {
|
|
||||||
var out LinkResultResp
|
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/email/merge", userID, "",
|
|
||||||
map[string]string{"email": email, "code": code}, &out)
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// LinkTelegram attaches a gateway-validated Telegram identity to the caller or
|
|
||||||
// reports a required merge (Stage 11).
|
|
||||||
func (c *Client) LinkTelegram(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
|
||||||
var out LinkResultResp
|
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram", userID, "",
|
|
||||||
map[string]string{"external_id": externalID}, &out)
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// LinkTelegramMerge merges the account owning a gateway-validated Telegram identity
|
|
||||||
// into the caller's (Stage 11).
|
|
||||||
func (c *Client) LinkTelegramMerge(ctx context.Context, userID, externalID string) (LinkResultResp, error) {
|
|
||||||
var out LinkResultResp
|
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/link/telegram/merge", userID, "",
|
|
||||||
map[string]string{"external_id": externalID}, &out)
|
|
||||||
return out, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats returns the caller's lifetime statistics.
|
// Stats returns the caller's lifetime statistics.
|
||||||
func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) {
|
func (c *Client) Stats(ctx context.Context, userID string) (StatsResp, error) {
|
||||||
var out StatsResp
|
var out StatsResp
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|
||||||
@@ -33,10 +32,7 @@ type Client struct {
|
|||||||
// backend lives on a trusted network segment, so the gRPC connection uses
|
// backend lives on a trusted network segment, so the gRPC connection uses
|
||||||
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
// insecure (plaintext) transport credentials (ARCHITECTURE.md §12).
|
||||||
func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) {
|
func New(httpURL, grpcAddr string, timeout time.Duration) (*Client, error) {
|
||||||
conn, err := grpc.NewClient(grpcAddr,
|
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
||||||
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("backendclient: dial push %s: %w", grpcAddr, err)
|
return nil, fmt.Errorf("backendclient: dial push %s: %w", grpcAddr, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the gateway's runtime configuration.
|
// Config holds the gateway's runtime configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// HTTPAddr is the public Connect/h2c listener address (host:port). It also
|
// HTTPAddr is the public Connect/h2c listener address (host:port).
|
||||||
// serves the admin console at /_gm when admin credentials are configured.
|
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
|
// AdminAddr is the admin reverse-proxy listener address. Admin is enabled only
|
||||||
|
// when AdminUser and AdminPassword are also set.
|
||||||
|
AdminAddr string
|
||||||
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
|
// LogLevel is the zap log level: "debug", "info", "warn" or "error".
|
||||||
LogLevel string
|
LogLevel string
|
||||||
// BackendHTTPURL is the base URL of the backend REST API (gateway -> backend).
|
// BackendHTTPURL is the base URL of the backend REST API (gateway -> backend).
|
||||||
@@ -33,11 +32,6 @@ type Config struct {
|
|||||||
// gateway calls it to validate Mini App initData and to deliver out-of-app push.
|
// gateway calls it to validate Mini App initData and to deliver out-of-app push.
|
||||||
// Empty disables the telegram auth path and the out-of-app push channel.
|
// Empty disables the telegram auth path and the out-of-app push channel.
|
||||||
ConnectorAddr string
|
ConnectorAddr string
|
||||||
// DefaultSupportedLanguages is the New Game variant gating set put on the Session
|
|
||||||
// for non-platform logins (web / email / guest), which carry no service container
|
|
||||||
// to declare one. The UI offers only variants in this set (en -> English; ru ->
|
|
||||||
// Russian + Эрудит). Defaults to all of them; a deployment may narrow it.
|
|
||||||
DefaultSupportedLanguages []string
|
|
||||||
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
|
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
|
||||||
// caps the number of cached sessions.
|
// caps the number of cached sessions.
|
||||||
SessionTTL time.Duration
|
SessionTTL time.Duration
|
||||||
@@ -46,8 +40,6 @@ type Config struct {
|
|||||||
PushHeartbeatInterval time.Duration
|
PushHeartbeatInterval time.Duration
|
||||||
// RateLimit configures the in-memory anti-abuse limiter.
|
// RateLimit configures the in-memory anti-abuse limiter.
|
||||||
RateLimit RateLimitConfig
|
RateLimit RateLimitConfig
|
||||||
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
|
||||||
Telemetry pkgtel.Config
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// RateLimitConfig holds the token-bucket limits per class. Public and admin are
|
// RateLimitConfig holds the token-bucket limits per class. Public and admin are
|
||||||
@@ -67,6 +59,7 @@ type RateLimitConfig struct {
|
|||||||
// Defaults applied when the corresponding environment variable is unset.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
const (
|
const (
|
||||||
defaultHTTPAddr = ":8081"
|
defaultHTTPAddr = ":8081"
|
||||||
|
defaultAdminAddr = ":8082"
|
||||||
defaultLogLevel = "info"
|
defaultLogLevel = "info"
|
||||||
defaultBackendHTTPURL = "http://localhost:8080"
|
defaultBackendHTTPURL = "http://localhost:8080"
|
||||||
defaultBackendGRPCAddr = "localhost:9090"
|
defaultBackendGRPCAddr = "localhost:9090"
|
||||||
@@ -74,14 +67,6 @@ const (
|
|||||||
defaultSessionTTL = 10 * time.Minute
|
defaultSessionTTL = 10 * time.Minute
|
||||||
defaultSessionCacheMax = 50000
|
defaultSessionCacheMax = 50000
|
||||||
defaultPushHeartbeatInterval = 15 * time.Second
|
defaultPushHeartbeatInterval = 15 * time.Second
|
||||||
defaultServiceName = "scrabble-gateway"
|
|
||||||
)
|
|
||||||
|
|
||||||
// supportedLanguages is the set of game languages a service may declare for the
|
|
||||||
// New Game variant gating; defaultSupportedLanguages is the non-platform fallback.
|
|
||||||
var (
|
|
||||||
supportedLanguages = map[string]bool{"en": true, "ru": true}
|
|
||||||
defaultSupportedLanguages = []string{"en", "ru"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRateLimit returns the built-in anti-abuse limits.
|
// DefaultRateLimit returns the built-in anti-abuse limits.
|
||||||
@@ -100,6 +85,7 @@ func Load() (Config, error) {
|
|||||||
var err error
|
var err error
|
||||||
c := Config{
|
c := Config{
|
||||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||||
|
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
|
||||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||||
@@ -109,11 +95,6 @@ func Load() (Config, error) {
|
|||||||
SessionCacheMax: defaultSessionCacheMax,
|
SessionCacheMax: defaultSessionCacheMax,
|
||||||
RateLimit: DefaultRateLimit(),
|
RateLimit: DefaultRateLimit(),
|
||||||
}
|
}
|
||||||
tel := pkgtel.DefaultConfig(defaultServiceName)
|
|
||||||
tel.ServiceName = envOr("GATEWAY_SERVICE_NAME", tel.ServiceName)
|
|
||||||
tel.TracesExporter = envOr("GATEWAY_OTEL_TRACES_EXPORTER", tel.TracesExporter)
|
|
||||||
tel.MetricsExporter = envOr("GATEWAY_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
|
|
||||||
c.Telemetry = tel
|
|
||||||
if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
|
if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
@@ -126,19 +107,16 @@ func Load() (Config, error) {
|
|||||||
if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); err != nil {
|
if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
if c.DefaultSupportedLanguages, err = envLanguages("GATEWAY_DEFAULT_SUPPORTED_LANGUAGES", defaultSupportedLanguages); err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminEnabled reports whether the admin console proxy should be mounted (both
|
// AdminEnabled reports whether the admin proxy should be served (an address and
|
||||||
// Basic-Auth credentials are configured).
|
// both Basic-Auth credentials are configured).
|
||||||
func (c Config) AdminEnabled() bool {
|
func (c Config) AdminEnabled() bool {
|
||||||
return c.AdminUser != "" && c.AdminPassword != ""
|
return c.AdminAddr != "" && c.AdminUser != "" && c.AdminPassword != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// validate reports whether the configuration values are acceptable.
|
// validate reports whether the configuration values are acceptable.
|
||||||
@@ -157,9 +135,6 @@ func (c Config) validate() error {
|
|||||||
if c.BackendGRPCAddr == "" {
|
if c.BackendGRPCAddr == "" {
|
||||||
return fmt.Errorf("config: GATEWAY_BACKEND_GRPC_ADDR must not be empty")
|
return fmt.Errorf("config: GATEWAY_BACKEND_GRPC_ADDR must not be empty")
|
||||||
}
|
}
|
||||||
if err := c.Telemetry.Validate(); err != nil {
|
|
||||||
return fmt.Errorf("config: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,31 +147,6 @@ func envOr(key, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// envLanguages parses a comma-separated language list (e.g. "en,ru") from the
|
|
||||||
// environment variable named key, returning fallback when it is unset. Every entry
|
|
||||||
// must be a supported language and the result must be non-empty.
|
|
||||||
func envLanguages(key string, fallback []string) ([]string, error) {
|
|
||||||
raw := strings.TrimSpace(os.Getenv(key))
|
|
||||||
if raw == "" {
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
var out []string
|
|
||||||
for part := range strings.SplitSeq(raw, ",") {
|
|
||||||
lang := strings.ToLower(strings.TrimSpace(part))
|
|
||||||
if lang == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !supportedLanguages[lang] {
|
|
||||||
return nil, fmt.Errorf("config: %s: unsupported language %q", key, lang)
|
|
||||||
}
|
|
||||||
out = append(out, lang)
|
|
||||||
}
|
|
||||||
if len(out) == 0 {
|
|
||||||
return nil, fmt.Errorf("config: %s must list at least one language", key)
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// envInt parses the environment variable named key as an int, returning fallback
|
// envInt parses the environment variable named key as an int, returning fallback
|
||||||
// when it is unset and an error when it is set but malformed.
|
// when it is unset and an error when it is set but malformed.
|
||||||
func envInt(key string, fallback int) (int, error) {
|
func envInt(key string, fallback int) (int, error) {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
pkgtel "scrabble/pkg/telemetry"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestLoadTelemetryDefaults verifies the gateway telemetry defaults: the
|
|
||||||
// "scrabble-gateway" service name and both exporters off.
|
|
||||||
func TestLoadTelemetryDefaults(t *testing.T) {
|
|
||||||
c, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if c.Telemetry.ServiceName != defaultServiceName {
|
|
||||||
t.Errorf("Telemetry.ServiceName = %q, want %q", c.Telemetry.ServiceName, defaultServiceName)
|
|
||||||
}
|
|
||||||
if c.Telemetry.TracesExporter != pkgtel.ExporterNone || c.Telemetry.MetricsExporter != pkgtel.ExporterNone {
|
|
||||||
t.Errorf("exporters = %q/%q, want none/none", c.Telemetry.TracesExporter, c.Telemetry.MetricsExporter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported
|
|
||||||
// set fails validation.
|
|
||||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
|
||||||
t.Setenv("GATEWAY_OTEL_METRICS_EXPORTER", "prometheus")
|
|
||||||
if _, err := Load(); err == nil {
|
|
||||||
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user