Commit Graph

18 Commits

Author SHA1 Message Date
Ilia Denisov 48614e622d feat(dict): make the build's dictionary version deploy-authoritative
The dictionary lives on a persistent volume seeded from the image once and never
re-seeded, so a redeploy that bumped BACKEND_DICT_VERSION left the new DAWGs
unreachable (the volume mount shadows the image copy) and the active version stuck
at whatever the admin console last installed — a native offline-first client then
fetched the server's older pinned version over the network instead of using its
bundled dict.

On boot the backend now DELIVERS the build version onto the volume add-only, from a
second unshadowed image copy at BACKEND_DICT_SEED_DIR, into DICT_DIR/<version>/ when
absent (the flat seed and prior uploads untouched, so in-flight games keep theirs),
and InitActiveVersion makes the build version active — except a console-installed
version NEWER than the build (compared numerically) is not downgraded by a restart.
The .seed_version guard still protects the flat seed's label (a bump is a new
subdirectory, never a relabel). The console stays for out-of-band updates.

Docs: ARCHITECTURE.md §5, deploy/README.md, seedmarker.go. Tests: engine.DeliverVersion
(add-only, idempotent, flat-seed skip), compareDictVersions, and the dictionary-update
integration test's deploy-delivers path.
2026-07-15 00:21:20 +02:00
Ilia Denisov eef90a152e feat(payments): per-channel Robokassa shops on the direct rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Split the single Robokassa direct rail into one merchant shop per channel
(web / android; ios later), chosen by the trusted X-Platform subtype, while
every shop still credits the one `direct` wallet — merchant-account separation
for accounting and receipts, not a new wallet.

- config: a channel-keyed shops registry (web seeds from the legacy vars or
  _WEB_*, android from _ANDROID_*); an empty shop leaves the rail dormant;
  per-shop validation.
- intake: the order picks its shop by subtype (unknown falls back to web); the
  per-shop Result callback is verified by that shop's own Password2 at
  /pay/robokassa/result/<channel> (the gateway extracts the channel; Caddy's
  /pay/* glob already forwards it — no Caddyfile change).
- persistence: an additive `shop` column on the order (migration 00015),
  recorded from the payment context, surfaced per entry in the admin report.
- standalone apps sign in by email only, so a direct purchase keeps its email
  anchor.
- docs: PAYMENTS (+ru) topology, deploy env vars + compose mapping, the
  decisions log (D41 revised for the ИП / 54-ФЗ move; D42-D44), the plan.

Contour-safe: dormant until shops are configured; the migration is additive
(no wipe); no client wire change. Fiscalization (Receipt/Email) and the gateway
`direct/android` subtype follow when the ИП / RuStore are live.
2026-07-14 14:57:34 +02:00
Ilia Denisov 36a5730e52 feat(payments): direct-rail order + intake handlers, config and reaper
Wire the Robokassa direct rail into the backend transport. POST
/api/v1/user/wallet/order (walletGate + a D36 confirmed-email gate for the
direct rail) opens a pending order and returns the signed Robokassa payment
URL. The internal, gateway-only /payments/robokassa/result endpoint verifies
the Result signature, credits the matched order exactly once via Fund (honoured
even if expired), records a succeeded payment event, and answers Robokassa's
"OK<InvId>". Add the Robokassa env config, an account HasConfirmedEmail check
(D36), the payment_events writer, and a periodic pending-order reaper. The
routes register only when a Robokassa merchant login is configured.
2026-07-09 16:59:34 +02:00
Ilia Denisov 8d2cd97e17 feat(adminalert): operator email on new feedback / word complaints
New adminalert worker polls for feedback + word complaints arriving since the last check
and coalesces a burst into one digest email per interval (5 min), inert unless a distinct
admin sender (BACKEND_SMTP_ADMIN_FROM) and recipient (BACKEND_ADMIN_EMAIL, comma-separated
allowed) are configured. The mailer gains a per-message From override and splits a
comma-separated To into separate recipients (go-mail needs them as a list). Feedback/game
stores gain CountSince/CountComplaintsSince. Unit tests cover the digest, the skip-when-
empty, and the recipient split.
2026-07-03 14:53:51 +02:00
Ilia Denisov c702f1bdac fix(email): explicit TLS mode for non-standard relay ports
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The 465-only implicit-TLS heuristic mis-classified Selectel's SSL port (1127),
which the mailer would have dialled with STARTTLS and failed. Add BACKEND_SMTP_TLS
(ssl|starttls) — empty still derives the mode from the port (implicit on 465, else
STARTTLS) — and dial implicit TLS with WithSSL()+WithPort so any port works, not
just 465. Wire SMTP_RELAY_TLS through compose/ci/prod/.env.example and document it
(Selectel: 1127 = SSL, 1126 = STARTTLS). Unit-tested.
2026-07-03 03:44:55 +02:00
Ilia Denisov 3877b23894 feat(account): brand and harden the email pipeline
Swap the net/smtp mailer for go-mail behind the existing Mailer seam: the
Message struct now carries a text + HTML body, TLS mode is chosen from the port
(implicit TLS on 465, else mandatory STARTTLS), a dial timeout bounds the
synchronous send, and no client certificate is needed. Add a branded, image-free,
mobile-friendly ru/en HTML template (with a plain-text alternative) rendering a
large readable code and an ignore-notice footer with a landing link.

Add BACKEND_PUBLIC_BASE_URL config (the canonical origin for the email footer
link, never the request Host — anti-injection), required when a relay is
configured.

Fix the email-login address squat: ProvisionEmail creates the account flagged
is_guest until the code is confirmed, so an abandoned login is reaped like any
guest and its address freed; confirming (login or link) clears the flag. Seed the
new account's language from the client, plumbed through the email-login request.

The confirm deeplink, its transport surface and the send rate-limit land in
follow-up work.
2026-07-03 02:38:43 +02:00
developer d5fbaa3034 feat(export): server-rendered artifacts behind one signed download URL (#160)
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 1m2s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
The finished-game export (GCG + a new PNG of the final position) is one
signed, short-lived relative URL (game.export_url; HMAC-SHA256, 10-min
TTL, BACKEND_EXPORT_SIGN_KEY) resolved against the client's own origin
and delivered by the best affordance each platform has (five on-device
review rounds):

- TG Android/desktop: native showPopup chooser -> native downloadFile
  dialog (bridge-only chain, activation-safe).
- TG iOS: app-modal chooser -> OS share sheet with the fetched file
  (a popup callback cannot supply the activation the sheet needs).
- VK iOS: VKWebAppDownloadFile for both formats.
- VK Android: the PNG opens in VK's native image viewer, the GCG copies
  to the clipboard (the VK Android downloader hangs on any download,
  Content-Length/Range notwithstanding).
- VK desktop iframe / desktop browsers: plain anchor downloads.
- Mobile browsers: the OS share sheet (fetch-then-share).
- Legacy TG (< Bot API 8.0): app modal + GCG clipboard, no image option.

The PNG is rasterized on demand by the new internal `renderer` sidecar
(node:22-slim + skia-canvas + baked Liberation/Noto Color Emoji fonts)
executing the SAME ui/src/lib/gameimage.ts the ui project unit-tests;
the backend rebuilds the render payload from the journal +
engine.AlphabetTable, and the device date locale, IANA time zone and
localized non-play labels ride the signed URL. Nothing is stored — the
artifact re-derives from the immutable journal on each GET. The gateway
forwards /dl/* (caddy @gateway matcher extended) behind the per-IP
public rate limiter and serves bytes via http.ServeContent.

Deploy: renderer service in compose + prod overlay + rolling order +
prod push list; TEST_/PROD_EXPORT_SIGN_KEY secrets; the sidecar smoke
runs in the ui CI job. Docs: ARCHITECTURE, FUNCTIONAL(+_ru), UI_DESIGN,
TESTING, deploy/README, renderer/README.
2026-07-02 21:58:07 +00:00
Ilia Denisov c305363ccd feat(lobby): enter the game immediately and wait for the opponent inside it
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m4s
Quick auto-match no longer waits on a separate screen: Enqueue opens a real game seating the caller with an empty opponent seat (new game status 'open') and the player enters it at once. A second human searching the same variant+rule joins that open game; otherwise a background reaper seats a robot after a 90s + random 0-90s wait, pushing a new in-app opponent_joined event that fills the opponent card and re-enables resign and chat in place.

Matchmaking state is now the open games in the database (the in-memory pool, lobby.poll and lobby.cancel are gone), serialised by a per-bucket advisory lock. While a game is open the starter may move on their turn, but resign, chat and nudge are refused; the lobby and opponent card show "searching for opponent".

Schema edited in the baseline (no prod data): 'open' status, nullable game_players.account_id for the empty seat, and a games.open_deadline_at stamp; jet code regenerated.
2026-06-12 16:00:22 +02:00
Ilia Denisov 8881214213 R6(a): de-stage code, docs, READMEs; split stage6_test
Mechanical, behaviour-preserving removal of Stage N / TODO-N / phase (RN)
references from comments, doc-comments, service READMEs, the current-state docs
(ARCHITECTURE, FUNCTIONAL+_ru, TESTING, UI_DESIGN), config-file comments, and the
.fbs/.proto schema comments. PLAN.md / PRERELEASE.md / CLAUDE.md keep the stage
history.

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

go build/vet/gofmt clean across modules; integration typecheck and pnpm check green.
2026-06-10 16:56:03 +02:00
Ilia Denisov ab58062565 R3: backend rate-limit observability — ratewatch, auto-flag, admin throttled view
- accounts.flagged_high_rate_at baked into the R1 baseline (no prod data; the
  contour schema is wiped after merge); jet regenerated — the regen also picks
  up the previously missing game_drafts/game_hidden models.
- account.Store: FlagHighRate (set-once), ClearHighRateFlag, the flag in
  GetByID/ListUsers and a ListFlaggedHighRate review queue.
- New internal/ratewatch: ingests the gateway rejection reports, keeps a
  bounded in-memory episode window for the console and applies the
  conservative auto-flag (1000 rejected / 10 min, BACKEND_HIGHRATE_FLAG_*).
- POST /api/v1/internal/ratelimit/report (network-trusted, like
  sessions/resolve).
- Admin console: Throttled page (episodes + flagged accounts), a high-rate
  badge in the user list, the marker + operator clear action on the user card.
- Tests: ratewatch unit suite, report-route handler test, renderer cases,
  integration coverage for the store round-trip and the console flow.
2026-06-10 02:14:10 +02:00
Ilia Denisov dcd8de8b00 Stage 12: observability & performance (OTel/OTLP, domain metrics, guest GC)
Tests · Go / test (push) Successful in 11s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 10s
Tests · Integration / integration (pull_request) Successful in 11s
- pkg/telemetry: shared OTel provider bootstrap (none/stdout/otlp + W3C
  propagators + Go runtime metrics); backend/internal/telemetry becomes a thin
  facade keeping its gin middleware.
- Telemetry parity: gateway and the Telegram connector gain telemetry runtimes
  and config (GATEWAY_/TELEGRAM_ SERVICE_NAME + OTEL_*); otelgrpc instruments the
  backend push server, the gateway's backend+connector clients and the connector
  server. Default exporter stays none (collector/dashboards are Stage 14).
- Operational metrics (variant attribute on game-scoped ones): game_replay_duration,
  game_move_validate_duration, games_started_total, games_abandoned_total,
  game_cache_active, chat_messages_total{kind}, gateway edge_request_duration.
  Wired via the SetMetrics setter pattern (default no-op meter).
- TODO-3: account.GuestReaper deletes guests with no game seat past
  BACKEND_GUEST_RETENTION (default 30d, swept every BACKEND_GUEST_REAP_INTERVAL).
- Tests: pkg/telemetry exporter selection; game/social/edge metric recording via
  a manual reader; config (otlp accepted, guest knobs); inttest guest reaper.
- Docs: PLAN.md re-scopes Stage 12 and adds Stage 13 (alphabet-on-wire) + Stage 14
  (CI/deploy) with the agreed dictionary-versioning resolution; ARCHITECTURE 11/13,
  TESTING, the three READMEs and FUNCTIONAL(+ru) updated.
2026-06-04 14:22:15 +02:00
developer 3a640a17a4 Stage 10: admin console & dictionary ops (complaint review, hot-reload, broadcasts) (#11)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
2026-06-04 07:27:49 +00:00
Ilia Denisov 408da3f201 Stage 6: gateway edge (Connect/FlatBuffers over h2c, platform/email/guest auth, sessions, rate-limit, admin passthrough, live push bridge)
Tests · Go / test (push) Successful in 8s
Tests · Integration / integration (push) Successful in 11s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 10s
New public ingress and the first network edge. Framework + a vertical slice of
operations end-to-end; remaining ops reuse the same transcode pattern in Stage 7.

Contracts (new module scrabble/pkg):
- push.proto (backend->gateway gRPC server-stream) + scrabble.fbs (FlatBuffers
  edge payloads), committed generated Go; buf/flatc Makefiles (dev-time codegen).

Backend:
- REST handlers on the /api/v1 groups: internal session endpoints
  (telegram/guest/email login -> mint, resolve, revoke) and the user slice
  (profile, submit_play, state, lobby enqueue/poll, chat).
- internal/notify in-process Publisher hub + internal/pushgrpc gRPC server
  (BACKEND_GRPC_ADDR) streaming your_turn/opponent_moved/chat/nudge/match_found;
  emission in game.commit, social, matchmaker.
- migration 00005 accounts.is_guest; guests are durable rows excluded from stats;
  ProvisionGuest; email-as-login (RequestLoginCode/LoginWithCode).

Gateway (new module scrabble/gateway):
- Connect Gateway service over h2c (Execute + Subscribe), FlatBuffers<->JSON
  transcode registry, Telegram initData HMAC validator (seam), session cache,
  token-bucket rate limiter (3 classes), push fan-out hub, backend REST + push
  gRPC client, admin Basic-Auth reverse proxy.

go.work: use ./pkg, ./gateway + replace scrabble/pkg. CI: gateway/**, pkg/**
path filters; unit build/vet/test span all three modules. Docs (PLAN,
ARCHITECTURE, FUNCTIONAL+ru, TESTING, READMEs) updated; gateway/pkg unit tests +
guest/email-login integration tests.
2026-06-02 22:38:24 +02:00
Ilia Denisov 85baabe4ba Stage 5: robot opponent (pool, seed-derived strategy, move driver, matchmaker substitution)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 10s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 10s
- internal/robot: durable kind='robot' account pool (migration 00004); every
  per-game and per-turn choice derived deterministically from the game seed
  (restart-stable FNV mix); a background move driver; margin targeting (band
  1-30, closest-to-band); right-skewed [2,90]min delays (median ~10m);
  opponent-anchored sleep with +/-3h drift; daytime nudge reply + proactive
  12h nudge; friend/chat blocked via profile toggles.
- engine.Candidates (decoded ranked plays); game.Candidates + RobotTurns;
  social.LastNudgeAt.
- matchmaker: 10s wait then robot substitution (reaper) + Poll delivery seam.
- config (BACKEND_ROBOT_DRIVE_INTERVAL, BACKEND_LOBBY_ROBOT_WAIT,
  BACKEND_LOBBY_REAPER_INTERVAL); main wiring + boot-time pool provisioning.
- metrics: robot account_stats (authoritative balance) + robot_games_finished_total
  OTel counter + per-finish log.
- docs: PLAN, ARCHITECTURE, FUNCTIONAL(+ru), TESTING, README; account.go comment.
- tests: robot strategy units, matchmaker reaper/Poll, engine.Candidates; inttest
  robot full-game / substitution / proactive-nudge.
2026-06-02 21:02:20 +02:00
Ilia Denisov bfa8797f8c Stage 4: lobby & social (matchmaking, friends, blocks, chat+nudge, invitations, profile, email, multi-player drop-out)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 9s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 9s
Engine: multi-player drop-out-and-continue with a per-game tile disposition (remove default / return), resigned seats skipped and excluded from the win, leaver rack never revealed; 2-player behaviour unchanged.

New domains (service/store, no HTTP yet): internal/social (friend request/accept graph, per-user blocks, per-game chat with nudge as a message kind, content filter via mvdan.cc/xurls/v2 + leet/separator normaliser + phone heuristic) and internal/lobby (in-memory variant-keyed matchmaking pool, friend-game invitations invite->accept with lazy 7-day expiry). account gains profile editing and the email confirm-code flow (Mailer seam: SMTP or log mailer).

Migration 00003_social.sql + regenerated jet. main wires the new services into the server (accessors for the Stage 6 handlers); robot substitution stays in Stage 5, REST/stream/push in Stage 6/8. Docs (PLAN, ARCHITECTURE, FUNCTIONAL+ru, TESTING, README) updated.
2026-06-02 19:29:30 +02:00
Ilia Denisov 751e74b14f Stage 3: game domain (lifecycle, journal+cache, hint, word-check, GCG, stats)
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 8s
Tests · Go / test (pull_request) Successful in 5s
Tests · Integration / integration (pull_request) Successful in 8s
internal/game drives the engine over a single match and owns everything the
engine does not: event-sourced persistence (a games row + an append-only decoded
move journal, with the live engine.Game kept warm in a cache and rebuilt by
replay on a miss), the play/pass/exchange/resign transitions with
validate-at-submit scoring, an unlimited score/legality preview, the hint
(per-game allowance + profile wallet), the word-check tool with complaint
capture, per-player game state, history and GCG export (Poslfit dialect), and a
background turn-timeout sweeper that auto-resigns overdue turns honouring each
player's daily away window. Like Stages 1-2 it is a service/store layer with no
HTTP; the gateway surface lands in Stage 6.

Engine: additive decoded domain API (Direction, SubmitPlay/SubmitExchange/
EvaluatePlay/HintView/Hand, MoveRecord.{Dir,MainRow,MainCol}, Registry.Lookup,
ParseVariant) so internal/game never imports scrabble-solver; and a Resign fix
so the resigner keeps their score yet never wins (the other player wins a
two-player game). Timeout reuses Resign.

Persistence: migration 00002 adds games, game_players, game_moves, complaints,
account_stats and extends accounts with away_start/away_end/hint_balance; go-jet
regenerated. account gained SpendHint. Config adds BACKEND_DICT_DIR (required),
BACKEND_DICT_VERSION, BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL, BACKEND_GAME_CACHE_TTL;
main loads the registry at boot (hard dependency) and starts the sweeper.

Tests: engine resign + decoded-API tests; game unit tests (GCG, away-window
boundaries, hint budget, cache, keyed mutex, payload); inttest integration
(lifecycle, replay equivalence, timeout sweep with away grace, resign stats,
hint policy, word-check/complaint, per-game-lock). Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +_ru, TESTING, README) updated.
2026-06-02 17:33:49 +02:00
Ilia Denisov eeaad62b10 Stage 1: backend foundation (Postgres, sessions, accounts, OTel)
Tests · Go / test (push) Successful in 11s
Tests · Integration / integration (push) Successful in 8s
- internal/postgres: pgx-over-database/sql pool (otelsql), embedded goose
  migrations into schema 'backend', committed go-jet code + cmd/jetgen tool.
- internal/account: durable accounts + unified telegram/email identities
  (UUIDv7 keys), find-or-create provisioning with unique-conflict handling.
- internal/session: opaque 256-bit tokens stored as a SHA-256 hash, revoke-only
  (no TTL); write-through cache gating /readyz; store + service.
- internal/telemetry: OTel tracer/meter providers (none/stdout) + request-timing
  middleware; internal/config gains Postgres + OTel env loading.
- internal/server: /api/v1 {public,user,internal,admin} skeleton + X-User-ID
  middleware; /readyz checks DB ping + cache; main wires
  telemetry -> db+migrate -> warm cache -> server.
- Tests: unit + integration (build tag 'integration', testcontainers
  postgres:17) for migrations, accounts, sessions, readyz; new integration.yaml.
- Docs: ARCHITECTURE, TESTING, PLAN refinements, root + backend READMEs.

Session/account REST handlers deferred to Stage 6 (gateway); OTLP + dashboards
to Stage 11.
2026-06-02 13:52:26 +02:00
Ilia Denisov effe6675bc Stage 0: scaffold monorepo, backend skeleton, docs, CI
Tests · Go / test (push) Successful in 32s
- go.work (Go 1.26.3) with backend module; deps added incrementally (gin+zap only)

- backend: /healthz + /readyz, env config, graceful shutdown

- docs: ARCHITECTURE, FUNCTIONAL (+ru mirror), TESTING

- PLAN.md (stage tracker + per-stage open details) and CLAUDE.md (per-stage workflow)

- .gitea go-unit CI (gofmt/vet/build/test)
2026-06-02 11:57:58 +02:00