Commit Graph

49 Commits

Author SHA1 Message Date
Ilia Denisov b918217497 feat(account): unlink provider + change-email edges (backend + gateway)
Unlink: POST /user/link/unlink (telegram|vk) via account.RemoveIdentity, refusing
the last identity; email is never unlinked. New fbs LinkUnlinkRequest + gateway
link.unlink op, returning the refreshed profile.

Change-email: purposeChange confirm-codes (RequestChangeCode/ConfirmChange) that
atomically replace the account's confirmed email (account.replaceEmailIdentity);
a new address owned by another account is refused without disclosure, never merged.
The one-tap deeplink handles purposeChange too. Reuses the LinkEmail* fbs tables;
gateway link.email.change.{request,confirm} ops + backendclient methods; branded
ru/en change-email copy.
2026-07-03 09:47:42 +02:00
Ilia Denisov a3eb4719de refactor(account): generalize RemoveEmailIdentity to RemoveIdentity(kind)
Generalize the email-erase store op to any identity kind (with the same
last-identity guard and, for email, the pending-confirmation cleanup) so the
profile Unlink control can reuse it for Telegram/VK. RemoveEmailIdentity stays as a
thin wrapper for the admin console.
2026-07-03 09:20:59 +02:00
Ilia Denisov 54af644429 fix(ui): localise the confirm screen, drop the brand on the error state
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
The session-less /confirm page defaulted to English, so it showed the English
app title. Carry the recipient's language on the deeplink (?lang) and setLocale on
load so the page matches the email. Show a localised brand wordmark (Эрудит / Erudit,
matching the email) on the success state only; the invalid/expired state now shows
just the message, no header.
2026-07-03 05:54:28 +02:00
Ilia Denisov 356bf1a5ba feat(admin): search users by email + erase a bound email
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m40s
Add an exact (strict) email filter to the /users list (UserFilter.EmailExact →
a kind='email' identity match) with a search input, and an 'Erase email' action on
the user card that deletes the bound email identity and its pending confirmations,
freeing the address. It refuses to remove the account's only identity
(ErrLastIdentity), which would leave it unreachable. Integration tests for both.
2026-07-03 05:30:30 +02:00
Ilia Denisov 77a18a3cc1 fix(account): clear the guest flag on a deeplink email link
ConfirmByToken attached the confirmed email to the account but, on the link path,
skipped ClearGuest — so a guest who bound an email via the one-tap deeplink stayed a
guest (the code-based flow clears it in the link service). Clear the flag on a free
link too, promoting the guest to a durable account; the profile live event then
refreshes the open session. Integration test added.
2026-07-03 05:18:24 +02:00
Ilia Denisov 5804f7266e fix(account): seed the email account display name from the local part
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 1m3s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
An email account was provisioned with no display name (unlike Telegram/VK, which
seed one), so an email login showed an empty name. Seed it from the email's local
part (before '@', trimmed and capped to the column width) on first contact; the
user can rename it later. Only new accounts are seeded — an existing account's name
is never overwritten.
2026-07-03 05:10:22 +02:00
Ilia Denisov 25d80bc31d feat(server): confirm-link endpoint for the one-tap deeplink
Add POST /internal/sessions/email/confirm-link: it verifies a deeplink token via
ConfirmByToken and, for a login, mints a session (the deeplink page signs in with
it); for a link, attaches the confirmed email and reports "confirmed" or
"merge_required" (the app drives the interactive merge). The token, not a request
session, is the authorization. Add LinkConfirmation.IsLogin() and integration tests
for the login, link and merge branches (the token is read from the mailed link).
The gateway RPC, live event and SPA route follow.
2026-07-03 04:13:48 +02:00
Ilia Denisov d5b4bba018 feat(account): restore the confirm deeplink token (domain layer)
Re-apply the deeplink backend deferred out of PR1a: migration 00006 adds
email_confirmations.purpose + link_token_hash (hand-edited jet), each issued code
now carries an opaque 256-bit token (only its SHA-256 stored), and ConfirmByToken
resolves a token to a login (confirm + clear guest) or a link (attach when free,
signal merge when owned elsewhere). issueCode now embeds the /app/#/confirm/<token>
link in the email. The confirm endpoint, gateway RPC and SPA route follow.
2026-07-03 04:07:10 +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 2207ac6132 feat(account): throttle confirm-code sends per recipient
Add an in-memory SendLimiter enforcing a one-per-minute cooldown and a
five-per-rolling-hour cap per recipient address, checked before provisioning or
sending in RequestCode, RequestLoginCode and RequestLinkCode. It guards against
email bombing and protects the relay quota. The limiter is injected in main
(nil in tests, so the domain suite is unaffected); ErrTooManyRequests maps to
HTTP 429.
2026-07-03 03:02:49 +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
Ilia Denisov 65c194264c feat(vk): embed the game as a VK Mini App
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/
entry, authenticates from VK's signed launch parameters, and provisions a
'vk' platform identity — the minimum to run the game in VK test mode.

- Gateway verifies the launch signature in-process (internal/vkauth:
  HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET,
  base64url) — a pure offline check, no side-service. New auth.vk op
  (gated on the secret), backendclient.VKAuth, /vk/ SPA mount.
- Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity
  kind widened to include 'vk' (migration 00005, expand-contract).
- UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot
  dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits
  the name from the signed params, so the client reads it via
  VKWebAppGetUserInfo as an unsigned display seed.
- Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through
  compose + .env.example + CI (TEST_) + prod-deploy (PROD_).
- Admin console: surface the VK user id (link to the VK profile) next to
  the Telegram id on the user card.
- Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK
  integration reference under .claude/.

Signature algorithm verified against dev.vk.com plus independent Node/Python
references and a %2C edge-case vector.
2026-06-27 11:37:31 +02:00
Ilia Denisov 03dfc29a54 feat(telegram): promo deep-link seeds English Scrabble for new users
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s
The promo bot button carries a configurable variant-seed start-param (default verudit_ru-scrabble_en). The gateway parses start_param from the validated initData and forwards it; the backend, on first contact only, seeds the new account variant_preferences from it (English Scrabble alongside the default Erudit).

No schema change (the scrabble_en CHECK is already in the baseline) and the gateway<->backend REST field is additive, so the rolling deploy is safe in either order. TELEGRAM_PROMO_START_PARAM configures the payload (empty forwards the user own /start payload). Covered by account unit tests, a gateway transcode test, and an integration test asserting new-only seeding.
2026-06-23 21:51:52 +02:00
Ilia Denisov ef2c2d1eb9 feat(account): seed the time zone from the client's detected offset at creation
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the
robot's sleep window and the turn-timeout away-window sweeper — both anchored to the
account zone via account.ResolveZone — ran on UTC for every fresh player, skewing
robot-game timing until a manual Settings save. Seed the zone at creation instead, from
the client's detected "±HH:MM" offset.

- Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest,
  GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the
  code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the
  gateway transcode + backend client, and the backend auth handlers into
  ProvisionTelegram / ProvisionGuest / ProvisionEmail.
- create() now writes time_zone explicitly: the validated detected offset, or 'UTC'
  (equal to the column default) when absent or malformed — deterministic, never guessed.
  The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing
  accounts keep 'UTC'. An existing account is never overwritten on re-login.
- A detected zero offset is stored as "+00:00" (the zone is known and equals UTC),
  distinct from the "UTC" default that means "unknown" — which the feedback console's
  three-zone Filed display already reflects.
- Guard the guest handler against an empty payload (the bootstrap historically carried
  none) so it degrades to no-seed rather than panicking in GetRootAs*.
- Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty
  cases and the not-overwrite rule; codec round-trip for the three auth encoders.
  ARCHITECTURE + FUNCTIONAL(+ru) updated.
2026-06-22 18:43:24 +02:00
Ilia Denisov 6b6362a629 fix(account): Telegram display-name falls back to the @username verbatim
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
When the Telegram first name yields no usable letters, fall back to the @username
taken whole (trimmed + length-capped, never character-stripped like the real name)
rather than a sanitized form; the generated placeholder is reached only when no
username is set. Precedence: real name -> @username (verbatim) -> placeholder.
2026-06-22 09:11:36 +02:00
Ilia Denisov a404513037 feat(telegram): chat-gate observability + grant on first registration
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m0s
Two follow-ups from a contour test where a user joined the chat, then
registered, and got no write access — with silent logs.

Observability: log every chat_member update (chat id, configured id, user,
old->new status), the eligibility result and the grant outcome; plus a startup
self-check that warns loudly when the bot is not an administrator in the chat
with the restrict-members ("Ban users") right — the common misconfiguration,
previously invisible in the logs.

Grant on first registration: a user who joins the moderated chat BEFORE
registering is covered by no chat_member event, so the join-time grant never
fires for them. ProvisionTelegram now reports first contact, and the Telegram
auth handler emits chat_access_changed on it, so the gateway re-evaluates and
grants write access if the user is already in the chat.
2026-06-21 15:19:21 +02:00
Ilia Denisov e71e40eef5 feat(telegram): promo bot + channel-chat moderation gate
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s
Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
2026-06-21 14:46:51 +02:00
Ilia Denisov 57c778f9b2 feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

The migration squash is deferred to a follow-up PR.
2026-06-20 14:23:25 +02:00
developer 8793bd34f2 feat(stats): best-move word, moves & hint-share, and a hint-count fix (#81)
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m8s
The statistics screen gains real depth, plus a hint-count bug fix found along the way.

- Best move per variant: the screen shows the actual best-move word (drawn as game
  tiles; a wildcard shows its letter but no value), broken down by game variant, empty
  variants omitted. New account_best_move table, written at game finish.
- Moves & hint share: two new lifetime tiles — the player's play count and the share of
  plays that used a hint — from summed account_stats counters (moves, hints_used).
  Honest-AI games are excluded, like the rest of the stats.
- Hint-count fix: the in-game hint badge no longer goes stale across games. The global
  wallet now rides the wire apart from the per-game allowance (wallet_balance on
  StateView/HintResult/StatsView), so the client reads the live wallet rather than a
  per-game snapshot; game_players.hints_used now counts every hint (allowance + wallet),
  its true per-game total.
- Account merge: sums the new moves/hints_used counters and merges the per-variant best
  moves (higher score kept), which it previously dropped.
- Admin: the user card shows Moves and Hints used.
- UI polish: tab/label wording, game-over text, and e2e selectors hardened against label
  changes.

All wire additions are trailing (backward-compatible). Docs (ARCHITECTURE, FUNCTIONAL +ru,
UISN_DESIGN) updated in step.
2026-06-17 22:17:27 +00:00
Ilia Denisov 183e08ec80 feat(robot): per-game display names from a wide name corpus
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 48s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Decouple the displayed opponent name from the small pool of durable robot
accounts: the disguised auto-match robot now gets a freshly composed name each
game, stamped on a new game_players.display_name seat snapshot. The snapshot
also captures humans' names, freezing what an opponent sees for the life of a
game (a later rename no longer rewrites past games); readers fall back to the
account's current name for pre-migration rows.

Names come from a wide composed corpus (internal/robot/namevariety.go): Western
locales (EN/DE/ES/IT/FR/PT), native Japanese/Chinese names, a gender-agreed
Russian pool, and human-style handles. Routing keeps Pick's spirit -- a Russian
game draws Cyrillic + <=20% Latin and never a CJK script; an English game the
full corpus -- via robot.PickNamed.

Loosen account.ValidateDisplayName (and the UI mirror) to admit a trailing run
of up to five digits, so "Player2007"-style handles are valid for humans too
and the disguised robot stays indistinguishable.

Migration 00007 adds game_players.display_name (additive, NOT NULL DEFAULT '');
jet regenerated. Docs (ARCHITECTURE 7, FUNCTIONAL + _ru, PLAN, README) updated.
2026-06-16 12:28:04 +02:00
Ilia Denisov 0946a3f66c feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
Turn the gated-off mock banner into a real advertising subsystem (backend +
admin half; the UI rotation lands in PR2).

- internal/ads: campaigns (percent weight + validity window; a perpetual,
  undeletable default that fills the remainder up to 100%), 1..N bilingual
  messages (en+ru), global display timings; ActiveSet computes the
  window-filtered, default-remainder, GCD-reduced, language-resolved rotation
  feed. Smooth-weighted-round-robin math is unit-tested.
- migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded
  default campaign + house message + default timings.
- eligibility = !paid_account && hint_balance==0 && !no_banner role (new role;
  guests qualify). The resolved feed rides the profile.get response (no new RPC,
  works for guests, nothing distinct to filter); language by service_language.
- live update: a notify `banner` sub-kind (re-poll signal) published when an
  operator grants hints or grants/revokes no_banner, so the client shows/hides
  in place.
- admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD
  with reorder, default protection, clamped timings.
- wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it.
- docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker
  (incl. the deferred app.load aggregator note).
2026-06-15 23:00:19 +02:00
Ilia Denisov 419ea11b14 feat(feedback): in-app user feedback with admin review and account roles
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 47s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
User-facing Feedback screen (Settings -> Info, registered accounts only): a
message (<=1024 runes) plus one optional attachment, an anti-spam gate (one
unreviewed message at a time), and the operator's inline reply with a
Settings/Info badge. Server-rendered admin console section (/_gm/feedback):
unread/read/archived queue with per-user search, detail with read/reply/
archive/delete/delete-all, safe attachment serving (nosniff, images inline via
<img>, others download-only). Introduces account_roles, the first per-account
role table; feedback_banned blocks only feedback submission, granted/revoked
from /users and the delete-with-block action.

- migration 00004_feedback (feedback_messages + account_roles) + jetgen
- backend internal/feedback (store+service), internal/account/roles.go
- wire: FlatBuffers feedback.submit/get/unread; gateway guest gate (Op.NonGuest,
  is_guest via session resolve) -> guest_forbidden before any backend call
- reply push reuses NotificationEvent with a new admin_reply sub-kind
- UI: /feedback route + screen, attachment picker, badge, channel detection, i18n
- tests: feedback unit (Go+UI), gateway guest-gate, inttest lifecycle, e2e
- docs: PLAN stage 19, ARCHITECTURE s15, FUNCTIONAL(+ru), TESTING, READMEs
2026-06-15 12:23:10 +02:00
Ilia Denisov 192e4a2433 feat(admin): grant hints to a user's wallet from the console
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m5s
Add an "Add hints" form on the admin user card that additively tops up a
player's hint wallet (1-100 per grant). The grant is raise-only by
construction (an additive UPDATE never lowers the balance) and stays correct
under a concurrent in-game spend; a per-grant cap bounds a fat-finger, since
the console can never reduce a wallet.

The in-game hint policy is unchanged and already correct: a game offers the
per-seat allowance plus the wallet, spending the allowance first and the
wallet only after (covered by TestHintPolicy).
2026-06-14 23:21:30 +02:00
Ilia Denisov 290874720f perf(admin): cache the suspension gate lookup
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 46s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m4s
The suspension gate runs CurrentSuspension on every authenticated request. Add a write-through in-memory cache on account.Store keyed by account id, invalidated on Suspend/LiftSuspension, with the cached entry re-evaluated against the wall clock so a temporary block lapses without an explicit invalidation. Single-instance, matching the deployment (one shared Store). Keeps the gate off the database on the hot path while a block still takes effect on the next request.
2026-06-14 22:24:57 +02:00
Ilia Denisov d1ba666495 feat(admin): manual account blocking (suspensions)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 45s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m10s
Operator-driven hard block, the counterpart to the soft high-rate flag: permanent or until a date, with an optional reason chosen from an editable en+ru picklist (snapshotted onto the block). A block forfeits the player's active games (opponent wins, as a resignation) and cancels their open matchmaking games. A backend gate refuses a blocked account on every /api/v1/user/* route except the block-status probe with 403 account_blocked, which threads through the gateway as the Execute result_code; the UI surfaces it as a terminal blocked screen and stops all push/poll. Temporary blocks self-expire; the operator can unblock at any time (lost games stay lost). Sessions are not revoked, so the blocked client can still reach the exempt block-status endpoint.

Backend: migration 00003 (account_suspensions + suspension_reasons) + jet regen; account suspension store; game.ForfeitAllForAccount; requireNotSuspended gate + block-status endpoint; admin console block/unblock + Reasons CRUD. Wire: fbs BlockStatus + account.block_status gateway op. UI: blocked screen, app state, transport/codec, i18n. Docs: ARCHITECTURE, FUNCTIONAL(+ru), PRERELEASE (AB).
2026-06-14 21:55:59 +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 d87c0fb10b Stage 17: cap display-name special characters at 5 (ui + backend)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 35s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m3s
display_name validation gains a rule: at most 5 special characters — the '.' / '_'
punctuation (spaces, which separate words, don't count) — so a still-well-formed name
can't be mostly punctuation. Mirrored in the Go ValidateDisplayName and the UI
validDisplayName; both unit-tested (5 ok, 6 rejected, 'J. R. R. Tolkien' ok). Docs:
FUNCTIONAL (+ _ru).
2026-06-09 07:42:47 +02:00
Ilia Denisov 356f490546 Stage 17 round 6 (#18, PR D): admin Messages moderation section
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 32s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m14s
A new /_gm/messages console page lists posted chat messages (nudges
excluded) newest-first — time, source (guest/robot/oldest identity kind),
sender (linked to the user card), IP, body, game (linked to the game card)
— searchable by sender name / external-id glob masks and pinnable to one
game (?game=) or sender (?user=), linked from the game and user cards.

The list query lives in social (raw SQL, kind='message', source via a SQL
CASE), reusing the now-exported account.LikePattern. Server-rendered
adminconsole MessagesView + messages.gohtml, 50/page via the shared pager.

Tests: adminconsole render case; backend integration AdminListMessages
(real Postgres) — nudge exclusion, game/sender pins, glob masks, source.
Docs: ARCHITECTURE section 8 chat moderation, PLAN round-6.
2026-06-08 20:10:27 +02:00
Ilia Denisov f916d5e0ca Stage 17 round 5 (L2): robot play-to-win intent + next-move ETA in the admin game card
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 29s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 1m14s
The admin game detail now shows, per robot seat, the game's deterministic play-to-win
decision (from the bag seed) and — while it is that robot's turn — its scheduled next-move
ETA (sampled think-time delay, deferred past the sleep window), plus a caption with the
~40% global target. Wiring: robot.PlayToWin/NextMoveAt/PlayToWinTargetPercent exports,
account.IsRobot, game RobotSchedule (seed + turn-start). Tests: NextMoveAt invariants
(never early, never in the sleep window), PlayToWin export, and an admin render integration
test asserting the intent + ETA + target appear.
2026-06-07 09:42:23 +02:00
Ilia Denisov 10412fee8e Stage 17 round 5 — backend/correctness bug fixes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Failing after 12s
CI / ui (pull_request) Successful in 30s
CI / gate (pull_request) Failing after 1s
CI / deploy (pull_request) Has been skipped
- Resign on the opponent's turn: engine ResignSeat(seat) resigns a specific seat
  (not just toMove); game.Resign bypasses the turn check and forfeits the actor's seat.
- Quick-match cancel was a UI no-op (only stopped polling): add the full path
  (REST /lobby/cancel -> gateway lobby.cancel -> client) and clear the matchmaker's
  pending result on Cancel, so a cancelled search is dequeued (no 'already queued', no
  later robot-substituted game). NewGame dequeues on cancel and on abandon.
- Lobby win/loss: result.ts ranked by score, so a 0-0 resignation read as a win.
  The winner now takes rank 1 and the viewer is placed from rank 2 — matching the
  game-detail screen.
- Friend request to a robot: robots no longer block requests; the request stays
  pending and expires (friendRequestTTL), mirroring a human who ignores it.
- Nudge cooldown: ErrNudgeTooSoon now maps to a distinct nudge_too_soon code with a
  correct message; the chat nudge button disables during the hourly cooldown; the
  nudge note reads 'Waiting for your move!' (button keeps the Nudge action label).
Tests: engine/service off-turn resign, matchmaker cancel-clears-result, friend-to-robot
inttest, result.ts 0-0 resignation, nudge_too_soon mapping.
2026-06-07 09:17:35 +02:00
Ilia Denisov 54497374e4 Stage 17 (#15): admin users people/robots toggle + display-name & external-id glob filters
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m11s
- account.ListUsers/CountUsers with a UserFilter: people vs robots (by a robot identity),
  case-insensitive '*'/'?' glob masks on display_name and any identity's external_id
- admin users list shows the real kind (robot/guest/registered), defaults to people,
  with a People/Robots toggle + a filter form; pager preserves the filter
- integration test for the filter; SQL verified against the live contour DB
2026-06-06 14:14:28 +02:00
Ilia Denisov b15fd30c4f Stage 17 (contour round 4a): quick fixes
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 8s
CI / integration (pull_request) Successful in 12s
CI / ui (pull_request) Successful in 27s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m6s
- #4 bag label: '{n} in the bag' / 'Bag is empty' (was 'Bag {n}')
- #6 allow a single trailing dot in display names (backend + UI regex + tests)
- #1 double-tap zooms toward the tapped cell, not the top-left
- #8 shuffle fires a short multi-pulse haptic
- #11 highlighted/flashing tiles darken their bottom edge too (shadow joins the flash)
- #13 toast slides up from the bottom and fades out
- #7 hide the logout button (kept wired behind `hidden`)
- #16 admin game seats: left-align numeric columns, clarify the 'Hints used' header
2026-06-06 14:08:40 +02:00
Ilia Denisov 635f2fd9fc Stage 17: backend defect fixes (nudge code, TG name, robot names/timing, multi-device push, move-duration metric + admin analytics)
- #3 nudge-on-own-turn: distinct result code nudge_own_turn + i18n (was reused 'not_your_turn')
- #2 sanitize connector registration name to the editable format; Player/Игрок-XXXXX fallback
- #5 variant-aware robot name pools (composed full/colloquial first + surname forms; ru gets <=20% latin)
- #4 move-number-aware robot move timing (early 1-5min -> late 10-90min, skew k=4)
- #7 emit move event to the actor too (multi-device sync); opponent_moved stays in-app only
- #1 live game_move_duration{variant,phase} histogram + admin console per-user min/avg/max columns and an inline-SVG move-time-by-move-number chart (offline from the journal)
- ProvisionRobot bypasses editor name validation (system names like 'Peter J.')
2026-06-06 09:59:12 +02:00
Ilia Denisov 8700fbfae1 Stage 16: deploy infra & test contour
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 19s
CI / deploy (pull_request) Failing after 1s
- backend + gateway multi-stage distroless Dockerfiles; the gateway embeds and
  serves the SPA at / and /telegram/ via go:embed (committed dist placeholder,
  real build baked in by the image's node stage)
- deploy/docker-compose.yml: backend + gateway + Postgres + Telegram connector
  (VPN sidecar) + OTel Collector + Prometheus (15d) + Tempo (72h) + Grafana,
  fronted by a caddy owning a single /_gm Basic-Auth (admin console + Grafana
  subpath); inter-service on a private network, only caddy on the edge network
- new metrics: backend accounts_created_total{kind} (robots excluded) and an
  in-memory gateway active_users{window=24h,7d} gauge
- CI: single .gitea/workflows/ci.yaml (unit/integration/ui + a gated test-contour
  deploy) on the new feature/* -> development -> master branch model; the old
  go-unit/integration/ui-test workflows are folded in; the connector-scoped
  compose is retired (superseded by deploy/)
- docs: ARCHITECTURE §11/§12/§13, root + gateway READMEs, CLAUDE.md branching,
  PLAN.md (stage 16 done + refinements + Stage 17 forward-notes)
2026-06-05 11:42:26 +02:00
Ilia Denisov e9f836db87 Stage 15: dual Telegram bots & language-gated variants
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Service-agnostic refinement of the owner's idea: the sign-in service returns a
set of supported game languages with the user identity, and the lobby gates the
New Game variant choice by it (en -> English; ru -> Russian + Эрудит).

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

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

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

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

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

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 07:10:21 +02:00
Ilia Denisov 695508042a Stage 8: regression tests for the review-round refinements
Tests · Go / test (push) Successful in 6s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 18s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 12s
Tests · UI / test (pull_request) Successful in 18s
Lock the polish behaviours so a future edit surfaces as a failing test:
- backend: UpdateProfile now rejects a bad name layout, an away window over 12h, and
  a malformed offset timezone (confirming it wires the Stage 8 validators); a new
  integration test accepts and resolves a "+03:00" offset timezone.
- e2e (mock): the lobby notification badge count, the play-with-friends required
  game type + invitation send, the in-game add-to-friends flipping to a disabled
  "request sent", the profile-edit invalid-name Save guard, and the chat send/nudge
  icon buttons.
2026-06-03 23:22:50 +02:00
Ilia Denisov acbb2d8254 Stage 8 polish: profile validation, finished-game UI, badge + Safari fixes
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 11s
Tests · UI / test (push) Successful in 17s
Owner-review follow-up on the Stage 8 branch:
- Friend code is copyable (📋 + toast). The lobby notification badge is fixed —
  it had inherited the hamburger-bar style — into a proper round count dot.
- Safari: min-width:0 on flex text inputs (friend code, profile, chat) so they
  shrink instead of pushing the adjacent button off-screen.
- Profile editing is validated on both the UI and the backend: display-name format
  (letters joined by single space/./_ separators, no leading/trailing/adjacent
  separators, <=32 runes), a UTC-offset timezone picker (account.ResolveZone parses
  ±HH:MM or a legacy IANA name), a 10-minute away grid capped at 12h (wrap-aware),
  and email format; Save is disabled and invalid fields red-bordered until valid.
  Language stays in Settings.
- In a game, an "add to friends" menu item flips to a disabled "request sent"; chat
  send/nudge became ⬆️/🛎️ icon buttons.
- A finished game drops its last-word highlight, hides Check word / Drop game,
  disables zoom, and draws an inert (greyed) footer instead of hiding it.

Tests: account validators (name/away/zone), UI profileValidation, e2e for the
finished-game footer/menu and the copy control. Docs (PLAN, ARCHITECTURE,
FUNCTIONAL +ru, UI_DESIGN) updated for the display-name rule, UTC-offset timezone
and the 12h away window.
2026-06-03 22:12:59 +02:00
Ilia Denisov d733ce3119 Stage 8: UI social/account/history surfaces
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 13s
Tests · UI / test (push) Successful in 16s
Wire the deferred Stage 7 surfaces end-to-end (UI -> gateway transcode ->
backend REST -> existing domain services): friends (incl. one-time friend
codes), per-user blocks, friend-game invitations, profile editing + email
binding, the statistics screen, and the in-game history + GCG export.

Friends gain two add paths (interview decision, a deliberate plan change):
one-time 6-digit codes (friend_codes table, 12h TTL, single-use, rate-limited
redeem); and play-gated requests (shared game required) where an explicit
decline is permanent, an ignored request lapses after 30 days, and a code
bypasses a decline. Migration 00006 widens friendships_status_chk and adds
friend_codes.

Lobby notification badge is poll + push: a new generic `notify` event drives
it live; the client polls on open/focus. Language stays a single Settings
control that writes through to the durable account's preferred_language. GCG
export is finished-only (game.ErrGameActive) and shares/downloads the .gcg file.

Tests: backend unit + inttest (friend gate/decline/code, ListInvitations,
GetStats, GCG gate), gateway transcode round-trips + notify constructor, UI
vitest (codecs, win-rate, share choice) + Playwright social specs. Docs: PLAN
(Stage 8 done + refinements + TODO-5), ARCHITECTURE, FUNCTIONAL(+ru), UI_DESIGN,
TESTING, module READMEs.
2026-06-03 19:47:40 +02:00
Ilia Denisov 7a48327ab6 Stage 7 (wip): docs bake + stage renumber (insert UI Stage 8, shift +1)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · UI / test (push) Failing after 51m19s
- PLAN.md: new Stage 8 (UI social/account/history); Telegram->9, Admin->10, Linking->11, Polish->12; tracker + Stage 7 refinements; split the Stage 6 'wired in Stage 7' note between 7 and 8
- ARCHITECTURE: promote ui to current (slice scope, board-replay, codegen, theming, mock)
- FUNCTIONAL(+ru): client-app section with the Stage 7/8 split
- README + ui/README + CLAUDE.md: UI build/run/test, codegen, pnpm notes
- bumped Stage 8-11 refs (+1) across docs and code comments
2026-06-03 01:01:58 +02: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