Promote development → master (initial production release: pre-release line + Stage 18) #104
Reference in New Issue
Block a user
Delete Branch "development"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Promote development → master for the initial production release.
Brings the full pre-release line (R1–R7 + the ad-hoc phases: TX split connector, AG anti-abuse, CM channel moderation, SB single bot, FM first-move draw, …) and the Stage 18 prod-deploy machinery (two-host registry rollout, rolling + auto-rollback, Ansible provisioning, host-memory monitoring) onto the production trunk.
Test-only CI (no auto-deploy on a master PR). After merge, the production cutover is the separate manual
prod-deployworkflow (workflow_dispatchfrom master,confirm=deploy) — nothing auto-deploys to prod. DNS forerudit-game.ru(+www) already resolves to the main host, so ACME is ready.- 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)- deploy/docker-compose.yml: mount the provisioned dashboards at /etc/grafana/dashboards, not /var/lib/grafana/dashboards — the grafana-data volume mounts over the latter and shadows the nested bind, so the provider logged "readdirent /var/lib/grafana/dashboards: no such file or directory". dashboards.yaml provider path updated to match. - Connector telemetry stays OTLP. The VPN sidecar's netns reaches the collector's internal IP fine (connected route, off-tunnel), but the sidecar's DNS hijacks name resolution: AWG_CONF must NOT carry a DNS= directive, else otelcol won't resolve ("produced zero addresses"). Without DNS= the netns uses Docker's resolver (resolves both otelcol and api.telegram.org). Documented in deploy/README.md (AWG_CONF row + wiring note), ARCHITECTURE §13, compose comment.Root cause of the Grafana "readdirent /etc/grafana/dashboards: no such file or directory": the CI runner checks out into an ephemeral act workspace that is removed after the job, so binding the compose config files straight from it dangles the mounts in the long-lived containers (verified the act source dir is emptied after the job). caddy/otelcol/prometheus/tempo read their config once at startup so they survive, but would break on a restart — same latent bug. Fix (mirrors ../galaxy-game's $HOME/.galaxy-dev/monitoring): the deploy job seeds the config dirs to a stable $HOME/.scrabble-deploy and the compose binds them via ${SCRABBLE_CONFIG_DIR:-.} (local runs keep "."). Documented in the compose header, deploy/README.md and the ci.yaml step.Complete the client-side draft feature on top of the shipped backend foundation (the game_drafts store/service): - FB: DraftRequest{game_id,json} + DraftView{json} (a draft get reuses GameActionRequest); regenerated committed Go + TS bindings. - Backend REST: GET/PUT /games/:id/draft, a draftDTO (rack_order/board_tiles) mapped to game.Draft. - Gateway: draft.get/draft.save transcode forwarding the composition JSON verbatim (json.RawMessage both ways -- no double-encode). - UI: debounced save of the rack order + board tiles and restore on load (lib/draft.ts), plus #5 -- tiles may be arranged on the opponent's turn (placement relaxed; the preview and Make-move stay your-turn-only, so an off-turn draft is position-only). Tests: backend handler validation, gateway pass-through round-trip, UI draft/codec units, and a draft-restore e2e.The Telegram 'your turn' notification now names the opponent and recaps their last move (voiced as the opponent: «{name}: my move — «WORD». Score 120:95» for a scoring play; a short 'swapped / passed, your turn' otherwise), and a new game-over notification reports the result + final score when a game ends by any path (closing play, all-pass, resign, timeout). Scores are recipient-first (the reader's score leads), 2-4 players (120:95:80). - schema: YourTurnEvent gains opponent_name/last_action/last_word/score_line (appended, backward-compatible); new GameOverEvent{result, score_line}. Go + UI bindings regenerated (flatc 23.5.26 + pnpm codegen). - backend: notify.YourTurn enriched + notify.GameOver; emitMove resolves the mover's name and emits per-recipient (your_turn to the next mover, game_over to every seat), with recipient-first score lines built in one place. - gateway: game_over joins the out-of-app whitelist (routing.go). - connector: render builds the enriched your_turn + game_over text per language (en/ru). - tests: notify round-trip (enriched + game_over), emit (enriched fields + game_over to all seats / per-seat result), connector render (en/ru), routing; integration replay (play → your_turn with real name; resign → game_over) green. - docs: ARCHITECTURE push catalog + out-of-app set, FUNCTIONAL (+ _ru), PLAN tracker.Following the in-game bar, the Connecting indicator now also visually disables the other proactive (server-sending) controls while offline: chat send + nudge, profile save / link email|telegram / merge-confirm, friends (redeem, get-code, accept/decline, unfriend, block, unblock), New Game (auto-match variant + send-invitation) and the lobby hide ❌. Purely local controls (board/rack/reset, menus, navigation, settings, copy-code) stay live. Each reads the global connection.online signal; full e2e + check green.- GATEWAY_MAX_BODY_BYTES (1 MiB): connect WithReadMaxBytes + http.MaxBytesReader on the public mux; explicit http2.Server MaxConcurrentStreams/IdleTimeout and an http.Server ReadHeaderTimeout (R2 report follow-up). - gateway_rate_limited_total{class} counter, Debug per rejection, a rejection tracker drained every 30 s into a Warn summary per key and a report POST to /api/v1/internal/ratelimit/report (feeds the admin view + auto-flag). - The dead AdminPerMinute/AdminBurst policy now guards the /_gm mount (429), ahead of its Basic-Auth. - resolve() logs the cause of infra session-resolve failures at Warn (the transient unauthenticated dips from the R2 run); unknown tokens stay silent.The receiver defaults to Docker API 1.25, but the contour daemon's minimum is 1.40 (it speaks up to 1.54), so otelcol crash-looped on start with "client version 1.25 is too old". Pinning api_version to 1.44 (accepted by both the receiver's bundled client and the daemon) starts the receiver cleanly — verified by running the image against the host socket ("Everything is ready", no start error).Tab bar: tapping a bottom-tab icon flashed a background — the icon square's :active press tint plus the default WebKit tap flash, the same pair removed from the lobby rows. Drop the press tint and set -webkit-tap-highlight-color: transparent on .tab. The selected-tab highlight (Settings / Comms hubs) stays. Startup slide: the route pane's in:slideX is local to its {#key} block, so it plays on that block's own first mount when app.ready flips — the lobby slid in on launch as if navigated into from another screen. Gate the slide duration to 0 for the first pane shown after boot (a `started` flag set right after it mounts), so launch is static while every later route change animates as before.The first attempt (the App.svelte `started` gate) targeted the first pane mount, but the slide is a second render. On a Telegram cold launch the URL fragment is Telegram's #tgWebAppData=... launch params, which the router parsed as notfound; bootstrap's navigate('/') then corrected it to the lobby asynchronously, re-keying the route pane (notfound -> lobby) and sliding the lobby in as if returning from a screen. A reload was static because the hash was already #/. Treat a Telegram launch fragment as the lobby root in the router, so the route is correct from the first pane (no re-key, no slide). Extract the pure hash->Route parsing into routeparse.ts so it unit-tests without a DOM, and revert the gate (the first pane never slid — local transitions skip the initial mount, as clean browser launches showed). Tests: routeparse unit tests (incl. the tgWebApp fragment); an e2e that launches with the fragment in the URL and asserts the lobby plus the normalised #/ hash.Add a per-game rule chosen on New Game for Russian variants (default off = the single-word rule; on = standard Scrabble). Off, only the main word along the play direction is validated and scored; perpendicular cross-words are ignored, including in robot move generation. The rule rides every create and enqueue request and joins the matchmaking key, so games and auto-match stay one uniform path; "Russian-only" is a UI affordance (English always sends standard and shows no toggle). - Engine: consume scrabble-solver v1.1.0's PlayOptions{IgnoreCrossWords}, threaded through engine.Options.MultipleWordsPerTurn -> playOpts() into validate, score and generate. - Backend: thread the flag through game CreateParams/Game + store (games column), lobby InvitationSettings + invitation row, and the matchmaker queue key (variant + rule); persisted, so a rebuilt-from-journal game keeps it. Baseline migration gains multiple_words_per_turn (DB not versioned); jet regenerated. - Edge: multiple_words_per_turn added to the EnqueueRequest / CreateInvitationRequest FlatBuffers tables (Go + TS regenerated) and threaded through the gateway. - UI: a "Multiple words per turn" toggle on New Game, shown for Russian variants only (auto-match and friend invite), default off; English silently sends standard. - Tests: backend engine/matchmaker; UI unit (gating) + Playwright e2e (solver corner-case + GCG fixtures ship in v1.1.0). Docs + PRERELEASE tracker updated.The paginated users and messages lists interpolate the pre-encoded filter query (url.Values.Encode) after the "?" in their pager and CSV-export links. There html/template treats it as a single query value and percent-encodes the structural "=" and "&" again, so "kind=robots" rendered as "kind%3drobots" and the multi-pair message filter collapsed -- every page step dropped the active filter. Type FilterQuery as template.URL so the already-escaped fragment is emitted verbatim (the attribute-level "&" -> "&" stays correct, the browser decodes it back). It is safe because url.Values.Encode output is strictly percent-encoded. games/complaints use status={{.Status}} -- a single value in proper query-value context -- and were never affected.- style the About feedback button and the form's Send/attach/remove buttons with the accent/border tokens used by the New Game CTA, so they follow light/dark theme (the previous .btn/.ghost classes were not defined globally); the attach button is a neutral 📎 icon button with an aria-label - show a round badge on the About 'Feedback' button when a reply is waiting - admin: one 'ban from feedback' checkbox shared by Delete and Delete-all (via button formaction); hide Mark read when already read and Archive when archived - e2e: match the About button by substring (its name gains the badge)Extend the openLink routing from the dictionary lookup to every external link shown inside the Mini App, so none triggers the WebView's 'open this link?' confirmation. A shared onExternalLinkClick handler resolves the anchor via closest() (so it also works delegated on {@html} content), backed by a pure routeExternalLinkInTelegram decision: only inside Telegram, only an external http(s) target=_blank link, excluding same-origin/in-app and t.me links (t.me keeps openTelegramLink). Applied to the word-check lookup, the About rules link, the Feedback operator-reply links, and the feature-gated announcement banner. Outside Telegram every anchor keeps its native target=_blank.Banner UX refinements (owner feedback): - Position: render the banner inside Header (under the title) instead of in Screen, so it sits in the same place on every screen. In the game the grown nav's spare height now falls below the banner (banner under title, board pinned to the bottom) — it no longer jumps to the game area. - Continuity: move the rotation into a persistent module engine (lib/bannerEngine) — the scheduler + timer live outside the components, so a navigation (which remounts the view) continues the cycle instead of restarting it. Each AdBanner only attaches as the DOM host and resyncs to the live message. - Fades: a long, scrolling message now fades at both ends. The fade is a {#if} transition:fade layer, independent of the scroll (the inner track's transform), so the two no longer interfere. Verified live (mock + Playwright): same position in lobby and game; the cycle continues across lobby↔game; opacity sampling shows fade-out + fade-in for the long message. Engine continuity unit-tested.The previous engine kept the scheduler running but the view re-`show()`-ed the current message on every (re)mount, replaying the fade on each navigation — which looked like the cycle restarting (especially for a single message). Now: - A mounted AdBanner reads the engine's live message (bannerCurrent) and renders it immediately, with no fade; attach no longer re-shows. Only a real advance fades. Verified: opacity stays 1.0 across a navigation, message preserved. - The fade is manual opacity on a .fadewrap layer (not transition:fade), kept independent of the scroll (inner track transform), so a long message still fades at both ends and a {#key} remount cannot force an intro fade. - A viewport size change (portrait↔landscape) re-measures the current message (remeasureBanner on resize/orientationchange, debounced) so the scroll re-evaluates for the new width — the owner accepts the restart on resize. Rotator gains restart(); engine gains bannerCurrent()/remeasureBanner(). Engine continuity + remeasure unit-tested.The seat display-name snapshot only reached the live-event path (seatNames); the REST DTO layer still resolved seat names from the account store (fillSeatNames), so the game screen and lobby list showed the robot's seeded account name ("Женя") while the your_turn toast showed its per-game name ("Звёздный_Барс2"). Carry the snapshot into gameDTOFromGame and have fillSeatNames fall back to the account only for a seat with no snapshot (a pre-snapshot legacy row). Friends and invitations keep account names (the persistent identity, not a per-game disguise).Cyrillic adjective+noun handles paired a masculine adjective with any noun, so a feminine noun read ungrammatically ("Вольный Комета"). Carry masculine + feminine adjective forms (cyrAdjective) and tag each noun's gender (cyrNoun); cyrillicNick now renders the adjective in the agreeing form ("Рыжая Комета", "Дикий Волк"), and a few feminine nouns (Комета/Звезда/Молния/Пантера/Рысь/Буря/Сова/Акула) are added for variety.A handle joining two meaningful words with a dot ("Тихий.Воин", "Hidden.Hunter") reads as machine-generated — people use "_" there, a dot only rarely. assembleHandle now picks "_" about nine times in ten and "." only about one in ten for the separator-joined form; the camelCase and bare-noun forms are unchanged.The "waiting for your move" popup was the nudge (chat.nudge), shown without a sender name. Resolve the nudger's per-game seat name server-side and carry it on a new NudgeEvent.sender_name field, so: - the in-app toast reads "<opponent>: Waiting for your move 🤭" (chat.nudgeBy); - the out-of-app Telegram push names the sender too (render nudgeBy); falling back to the plain phrase when the name is absent (RU mirrored). The your_turn toast already named the opponent and is unchanged. Lobby: when a card transitions into "your turn" or "finished" while the lobby is open, its status emoji blinks twice (two 1s fades); the opponent's-turn change stays in place. Blink state is keyed by game id (SvelteSet + per-id nonce/timer) so overlapping events animate in isolation; suppressed under reduce-motion. Toast: a per-message seq re-keys Toast.svelte, so the freshest toast cancels the previous one and replays its entrance, uniformly on every screen. Tests: notify.Nudge round-trip + render named/fallback (Go), game.Service.SeatName (integration), codec/i18n/gamePhase/shouldBlink (UI). Docs (FUNCTIONAL +_ru, UI_DESIGN) + PLAN TODO-7 (deferred FLIP card-relocation animation) updated.Persist per-message read state as a chat_messages.unread_seats bitmask (migration 00008): a text message seeds every recipient seat's bit, a nudge only the awaited seat's. A seat's bit clears when the player opens the move history or chat (POST /games/:id/chat/read, sent only when something is unread), and a nudge additionally clears when its recipient answers by moving (a wired game NudgeClearer, dependency-inverted so game keeps off social). UI shows a per-viewer unread dot in the lobby (next to the opponent) and the game score bar — the unread_chat game-view flag seeds it from authoritative REST views, live chat/nudge events raise it. Opening the move history counts as reading (even without entering chat): the 💬 fade-blinks twice and the client acks. Admin Messages gains an unread-only filter, a read/unread column, and a per-message card with the per-seat read breakdown. Observability: chat_read_duration histogram + chat_unread_messages gauge + social tracing.A vs_ai game is throwaway practice, so its finished history header no longer offers the 📤 GCG export (an empty slot keeps the comms icon pinned right). Docs note the AI exclusion; UI_DESIGN also records that confirming a resign reveals the full board (closes the history drawer, zooms out).Make a per-user block one-directional and non-destructive: the blocker stops receiving everything from the blocked user (chat, nudge, friend requests, invitations) and the matchmaker never pairs them, while the blocked user notices nothing — their sends still persist by the normal rules but are never delivered or surfaced (born-read). A block no longer deletes the friendship (an unblock cleanly restores it) and instant-reads any unread the blocked user had left for the blocker. - backend: a directional blockExists guard across chat/nudge/friends/invitations (store-but-hide for the blocked->blocker direction, refuse blocker->blocked); the matchmaker excludes a block-related pair (both directions) from auto-match; user_blocked/user_unblocked notifications to the blocker only (in-app only). - ui: the opponent score card gains a block ✖️ control mirroring add-friend (red "Block?" confirm, mutual-hide while confirming, struck name, hidden chat composer when blocked); optimistic apply + event confirm + rollback for both. - admin: the user card gains cross-linked blocks / blocked-by / friends lists. - docs: FUNCTIONAL(+ru), ARCHITECTURE §10 + decision record, UI_DESIGN, PRERELEASE.The ✅ box already ended at var(--pad) from the screen edge, but place-items: center centred the glyph inside the 56px box, so it sat ~14px left of the green preview caption (.scores, text-align:right at the same var(--pad)). Right-align the glyph (place-items: center end) so its right edge lands under the caption's.- The rack now animates the opening drop gap for a board-tile recall drag too (the reorder transition was gated on a rack-source drag only). - While a board tile is dragged over the rack (a recall, gap shown), the ✅ confirm and its preview caption are hidden so they don't sit over the gap; they return if the tile is dragged back out over the board. - A recall drop lands off the boardwrap, so its pointerup never reached the boardwrap handler and left a stale id in the active-pointer set — the next board swipe then read as multi-touch and the shuffle / history pull went dead. Reconcile the pointer when a drag ends or cancels. Regression e2e added.Svelte trims leading/trailing whitespace inside an element, so the literal `<span class="sep"> : </span>` rendered as a bare ":" ("123:123"). Emit the separator as a string expression `{' : '}`, which Svelte preserves, restoring "123 : 123".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.Add a prod-only, in-memory IP ban enforced at the edge, fed by three signals: sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the backend soft-flag's concern), a honeypot decoy-path hit (the contour caddy tags decoys with X-Scrabble-Honeypot and routes them to the gateway), and a honeytoken (a planted bearer, GATEWAY_HONEYTOKEN). A banned IP is refused with 429 by the abuseGuard middleware before any work — covering the Connect edge, the live stream and the static SPA/landing the per-op limiter never gated. The ban is off by default: it keys by the real client IP the shared-NAT test contour does not expose, so a ban there would be self-inflicted; detection still logs in the contour, only the ban action is gated (GATEWAY_ABUSE_BAN_ENABLED). Rejection bans last GATEWAY_ABUSE_BAN_DURATION; tripwire/honeytoken hits are near-zero-false-positive and earn longer fixed bans. Each ban increments gateway_abuse_banned_total{reason}. Operators see and lift active bans on the admin console's Throttled page; the gateway syncs its active set to the backend every 30s (POST /api/v1/internal/bans/sync, backend/internal/banview) and applies the operator unbans the response returns. PRERELEASE phase AG. Docs baked into ARCHITECTURE / FUNCTIONAL (+ru) / both READMEs.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.