Compare commits

..

44 Commits

Author SHA1 Message Date
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 11f89f6477 Merge pull request 'feat(offline): implicit net-state model, two-tier version gate, unified lobby' (#249) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m13s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 00:59:16 +00:00
Ilia Denisov 2d1fadb50c feat(offline): implicit net-state model, two-tier version gate, unified lobby
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured.

O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds.

Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00
Ilia Denisov 09e05eef18 docs(offline): stage the offline-model redesign (O1-O7 implementation plan)
Append the O1-O7 implementation stages to ANDROID_PLAN.md's "Offline-model
redesign" section (exact files, produced/consumed interfaces, acceptance
criteria, targeted tests; executed via stage-implementation, TDD from the pure
netState reducer in O1). Flag it in Progress as the next actionable work
(gated G-step-0), self-contained for a fresh session to resume from.
2026-07-12 22:07:49 +02:00
Ilia Denisov 73baf58002 docs(offline): design the offline-model redesign (net-state machine + two-tier gate)
Owner-approved brainstormed design in ANDROID_PLAN.md, resolving G-step-0:
remove the explicit offline toggle -> a single net-state machine
(online / connecting / offlineNoNetwork / offlineVersionLocked) with explicit
hysteresis + 12 enumerated edge cases; unify the lobby (server games greyed
from cache offline; identity recognises both ids); two-tier version gate (hard
degrades to forced offline with an "Update / Play offline" notice; new soft
recommended tier via an additive X-Update-Recommended header); keep server
vs_ai (offline->local, online->server, app decides by state); TG/VK
always-online. Exhaustive test matrix. Client + a small additive backend/wire
bit; contour-safe.
2026-07-12 21:57:40 +02:00
Ilia Denisov eec225c4ee docs(android): bake the version gate, identity model + native build into the docs
ARCHITECTURE §2 (client-version gate + frozen wire contract + gate x offline),
§3 (local-guest / server-guest / reconciliation identity), §13 (native Capacitor
build). FUNCTIONAL (+_ru) a new "Native app (Android)" domain. TESTING (the
clientver/gate Go tests, the native/update e2e + retry mapping, and the manual
on-device Android smoke checklist). deploy/README (GATEWAY_MIN_CLIENT_VERSION +
the wire-break bump discipline + the Android build/release runbook). ui/README
native VITE_* vars. Mark F done in ANDROID_PLAN.md.
2026-07-12 20:38:58 +02:00
Ilia Denisov ca2c6487cf feat(android): manual signed-APK CI workflow
Add .gitea/workflows/android-build.yaml (workflow_dispatch + confirm=build,
master-only; mirrors prod-deploy): builds the native SPA, bundles the offline
dicts, assembles a release APK as a run artifact. JDK 21 via setup-java; the
Android SDK is host-provisioned (host-executor runner) with a fail-fast verify
that also checks the runner user's access. Signing degrades gracefully — no
keystore => unsigned release APK, not a failure.

Wire ui/android/app/build.gradle: versionCode/versionName from the release tag
(-P props; '=' assignment, not the command form that binds .toInteger() to the
DSL setter's null return), plus a guarded signingConfigs.release from env.

Rename VITE_STORE_URL -> VITE_RUSTORE_URL (empty until publish).

Bake the E as-built into ANDROID_PLAN.md.
2026-07-12 20:27:07 +02:00
Ilia Denisov e7cb60c996 docs(android): record D on-device smoke + edge-to-edge fix as-built
Reconcile the Progress/§D status after this session: D is code-complete,
e2e-verified AND on-device-smoke-verified (Pixel_10 / API 37, airplane mode);
the smoke surfaced + fixed the edge-to-edge safe-area bug (top + bottom).
Mark D.5 done (verified by the e2e + the on-device vs_ai turn). Remaining for
D: the reconcile-online leg on-device (hits prod) + the deferred local-game-
visibility decision.
2026-07-12 19:31:22 +02:00
Ilia Denisov 49c53794f4 fix(ui): native header top safe-area under edge-to-edge
The earlier safe-area fix reached the bottom bars (they apply
--tg-safe-bottom) but not the header: its top inset lived only in a
Telegram-fullscreen-scoped rule, so on the native build the header .bar had
just 5px top padding and its content sat under the status bar (measured: the
title at y=11px behind the 54px bar; the back button untappable). Give .bar a
top inset from the SystemBars plugin's --safe-area-inset-top (native-only via
the plugin var; unset -> 0 on web/PWA/Telegram/VK, so no regression;
tg-fullscreen still overrides via specificity). Verified on-device (Pixel_10 /
API 37): the title moved from y=11 to y=65, clear of the status bar.
2026-07-12 19:22:12 +02:00
Ilia Denisov 4a0689a4ac fix(ui): native safe-area under Android 15+ edge-to-edge (WebView < 140)
targetSdk 36 forces edge-to-edge, so the WebView draws behind the system
bars. On Android WebView < 140, env(safe-area-inset-*) wrongly reports 0, so
the app chrome (which only read env()) drew under the status bar (top nav
untappable) and the gesture-nav home indicator (the game's centre Hint button
intercepted; side buttons fine). Capacitor 8's SystemBars core plugin (built
into @capacitor/core, insetsHandling:'css' by default) injects the correct
--safe-area-inset-* values on every WebView; consume them ahead of env():

  --tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))

Web / PWA / Telegram / VK are unchanged (--safe-area-inset-* is unset there,
so it falls back to env()). Verified on-device (Pixel_10 / API 37) via the
injected var — the emulator's auto-updated WebView 149 hides the env() bug,
so the visual alone won't reproduce it.
2026-07-12 18:58:07 +02:00
Ilia Denisov 0eb72ba955 feat(ui): hide Telegram/VK link buttons on the native build
The native (Capacitor) sign-in surface is guest + email only: VK ID
web-login is a full-page redirect to id.vk.com that cannot return into the
WebView, and the Telegram Login Widget is unreliable there. Profile now
gates telegramLinkable/vkLinkable on !nativeShell (clientChannel android/
ios), hiding both LINK buttons on native; email and account management —
including an existing link's redirect-free UNLINK — stay. Native tg/vk
login (native SDKs / deep-link OAuth) is a separate later stage.

Covered by e2e/native.spec.ts (the reconcile test opens Profile and asserts
no Link Telegram / Link VK buttons, email present).
2026-07-12 18:09:11 +02:00
Ilia Denisov e077258567 feat(ui): offline-first native boot + lazy server-guest reconciliation
Native (Capacitor) cold boot with no cached session now enters as a
device-local guest in auto-offline mode and lands straight in the lobby
(never /login), so the app opens and plays local vs_ai / hotseat with the
APK's bundled dictionaries and zero network. When the gateway becomes
reachable, reconcileServerGuest silently mints + adopts a server guest and
clears the auto-offline, lighting up online features. Web / PWA / Telegram /
VK are byte-for-byte unchanged.

- transport: exec gains { silent, allowOffline } so background reconciliation
  bypasses the offline kill switch (like the reachability probe) and never
  raises the terminal update overlay (a too-old client stays a local guest);
  new authGuestSilent on the client interface, the real transport and the mock.
- app: native no-session boot branch; reconcileServerGuest fired at boot, by
  the recovery poll and the online event; the poll routes the session-less
  guest through reconciliation, since checkReachable needs a token it lacks.
- native: initNativeShell tolerates a missing Capacitor bridge.
- e2e: new native.spec.ts (inject window.androidBridge; boot -> offline lobby,
  local vs_ai move, reconcile -> online, hotseat start) + playwright.config
  bundles the dawgs into dist-e2e/dict for the loader's bundled tier.
2026-07-12 18:03:52 +02:00
Ilia Denisov a035edfb54 docs(android): detail Stage D progress + remaining path + session decisions
Make ANDROID_PLAN.md self-contained so a fresh session resumes Stage D from the repo
alone. Records:
- the done foundations (D.1 bundled dicts + loader tier, D.2 local-guest identity) with
  their exact modules and the committed checkpoint (bcd5a1d);
- the session decisions (owner-approved): the blocking Login is bypassed on native only,
  soft registration reuses the Profile screen, the Telegram/VK link buttons are hidden on
  native (VK's web redirect strands the Capacitor app; native tg/vk is a later stage),
  local-guest name = localized common.guest;
- the native-gated loader-tier correction (a web `./dict/` fetch would hit the gateway's
  own session-gated /dict/ route, so the bundled tier is only tried on native);
- the detailed remaining path — D.3 boot rewrite with the exact blocking-login site
  (app.svelte.ts ~989-991), D.4 reconciliation + the silent seam wiring, D.6 Profile
  tg/vk gating — plus the offline-first e2e strategy (simulate native by injecting
  window.Capacitor) and its initNativeShell/@capacitor/app gotcha.
2026-07-12 17:01:23 +02:00
Ilia Denisov bcd5a1d02d feat(ui): offline-first foundations — bundled dicts + local-guest identity
The additive groundwork for the native offline-first experience (ANDROID_PLAN.md §D).
Inert until the native cold-boot lands: on the web these paths never fire, so web / VK /
Telegram behaviour is unchanged.

- ui/scripts/bundle-dicts.mjs copies the scrabble-dictionary release DAWGs into
  dist/dict/<variant>@<version>.dawg for the native pipeline (run after `pnpm build`,
  before `cap sync`).
- The dict loader gains a bundled tier between the IndexedDB and network tiers, attempted
  only on a native channel (a packaged app serves ./dict/<key>.dawg from its assets).
  Native-gated rather than the plan's "404 on the web" because the relative path would
  otherwise hit the gateway's own session-gated /dict/ route.
- __DICT_VERSION__ Vite define (from VITE_DICT_VERSION, default "dev") + its ambient
  declaration; the offline vs_ai / hotseat creates in NewGame fall back to it when a
  device-local guest has no profile-advertised version.
- New lib/localguest.ts: a persisted device-local guest id (no DB row); the offline vs_ai
  human seat uses it (and the localized common.guest name) when there is no server session.

svelte-check clean, vitest green, native + web builds clean. The cold-boot rewrite,
reconciliation, the Profile soft-sign-in gating and the offline-first e2e follow.
2026-07-12 16:41:19 +02:00
Ilia Denisov a57fd355ba feat(gateway,ui): client-version gate — turn away too-old builds
Introduce a minimum-supported-client gate so a future incompatible wire change
can turn away installed builds too old to speak it, cleanly, instead of letting
them crash on decode. It rides the outermost stable layer (an HTTP header), never
the FlatBuffers payload.

Gateway:
- New internal/clientver: dependency-free parse + compare of the leading
  MAJOR.MINOR.PATCH (a git-describe suffix is tolerated).
- GATEWAY_MIN_CLIENT_VERSION config (empty => gate dormant; validated at load).
- connectsrv checks the X-Client-Version header before decoding the payload:
  Execute returns result_code="update_required" (before the registry lookup),
  Subscribe returns FailedPrecondition. It fails open on an absent or garbled
  header — the header is a client-controlled compatibility signal, not an access
  control.

Client:
- Attach X-Client-Version on every call.
- A terminal update.svelte.ts store + a non-dismissable UpdateOverlay (native
  opens VITE_STORE_URL, web reloads); retry.ts maps FailedPrecondition to the
  update_required sentinel; a mock __update hook drives the e2e.

Wire-additive and contour-safe: no FBS/proto regen, no schema migration; the gate
stays dormant until GATEWAY_MIN_CLIENT_VERSION is deliberately set, so web / VK /
Telegram behaviour is unchanged. The silent reconciliation seam is deferred to the
offline-first work (its only caller). Tests: Go clientver/config/connectsrv gate
tests, retry.test.ts, Playwright update.spec.ts.
2026-07-12 15:47:41 +02:00
Ilia Denisov 2ea91a8354 fix(e2e): pin Web Share off in the desktop export tests (macOS WebKit)
The three "plain desktop browser" export tests (the PNG and GCG downloads plus the
legacy-Telegram clipboard copy) assumed navigator.share is absent — true for Chromium
and for WebKit on the Linux CI host, but desktop WebKit on macOS exposes a working Web
Share API. There shareUrlAsFile / pickGcgDelivery take the share branch, so no download
event fires and the "GCG copied to the clipboard" toast never shows, and the tests
failed only on macOS WebKit.

Pin navigator.share/canShare off in those three tests (a withoutWebShare helper that
mirrors the inverse stub the share-sheet test already uses), so the delivery path under
test is deterministic and identical across engines and OSes. Test-only; no production
change. Full e2e suite: 228 passed on Chromium + WebKit.
2026-07-12 15:14:31 +02:00
Ilia Denisov de003e862a feat(android): resolve gateway origin on native, skip SW, hide MVP purchases
Make the web SPA behave correctly inside the Capacitor native shell, where the
bundle loads from a local origin:

- New lib/origin.ts gatewayOrigin(): absolute URLs resolve to VITE_GATEWAY_URL on
  native (the finished-game export/share URL in Game.svelte and the Wallet
  site-root link), falling back to the page origin on web. transport.ts already
  resolved via VITE_GATEWAY_URL and is left as-is.
- Skip the PWA service worker on the native channel (the assets are already local;
  a worker would risk serving stale content across store updates).
- Hide the money-purchase UI in the MVP: new distribution.purchasesHidden() folds
  VITE_PAYMENTS_DISABLED and the Google Play flag. The Wallet "buy" tab shows a
  neutral, pointer-free note (wallet.purchasesSoon) for the RuStore MVP and keeps
  the RuStore stub Google-Play-only. A ?nopay mock force mirrors ?gp for the e2e.
- Native env types in vite-env.d.ts.

Client-only, contour-safe: no wire/proto/schema change; web/VK/Telegram unchanged.
Tests: origin + purchasesHidden unit tests, a ?nopay wallet e2e. svelte-check
clean, vitest green, web + native vite build clean.
2026-07-12 14:58:35 +02:00
Ilia Denisov aaf2825260 feat(android): add temporary «Э» launcher icon (pending full rebrand)
Generate the Android launcher/adaptive icons + splashes with capacitor-assets from ui/assets/icon.png (the existing maskable brand mark upscaled 512→1024) as a placeholder until the mandatory single-vector icon rebrand recorded in ANDROID_PLAN.md. The adaptive icon insets the foreground 16.7% into the safe zone; the AndroidManifest is untouched (its icon refs already point at @mipmap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 02:05:46 +02:00
Ilia Denisov 0f5db0ee91 feat(android): scaffold Capacitor 8 native project + hardware Back button
Add @capacitor/{core,android,app,cli,assets} to ui/, a capacitor.config.ts (appId ru.eruditgame.app, appName Эрудит, webDir dist — bundle model, no server.url), and the generated ui/android/ Gradle project (tracked; build outputs and the machine-specific local.properties gitignored, keystore patterns un-commented so signing material can never be committed). Wire the Android hardware Back button in ui/src/lib/native.ts behind a dynamic @capacitor/app import (web/mock bundles never load it), called from App.svelte onMount and reusing routeDepth for the navigation-root check. Whitelist sharp in pnpm-workspace.yaml for @capacitor/assets icon generation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:34:25 +02:00
Ilia Denisov 53b33073ac docs(android): fix plan (dict source, Capacitor 8 / SDK 36 / JDK 21) and record scaffolding progress + native-build notes
Correct ANDROID_PLAN.md: bundled offline dictionaries come from the scrabble-dictionary release (DICT_VERSION), not scrabble-solver; pin the toolchain to Capacitor 8 (compileSdk/targetSdk 36, minSdk 24, JDK 21 — @capacitor/android compiles at Java 21). Add a Progress section marking the scaffolding milestone done, and capture the native-Android build recipe + toolchain gotchas in .claude/CLAUDE.md so a fresh session resumes without re-deriving them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 01:34:25 +02:00
developer e3c5cff7b7 Merge pull request 'docs: Android native plan + agent field notes' (#248) from feature/android-native into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / conformance (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Has been skipped
2026-07-11 21:02:05 +00:00
Ilia Denisov 0c5e40c509 docs: capture agent field notes; drop RuStore-steering for the GP variant
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-11 22:53:26 +02:00
Ilia Denisov edaf2dfd9e docs: add ANDROID_PLAN.md — native Android (Capacitor) RuStore MVP plan 2026-07-11 22:42:41 +02:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 3ce460f72a Merge pull request 'feat(edge): community IP blocklist (Spamhaus DROP) at the gateway' (#246) from feature/edge-blocklist into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 24s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m11s
CI / deploy (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
2026-07-11 11:39:47 +00:00
Ilia Denisov 4ba9da6721 feat(edge): community IP blocklist (Spamhaus DROP) at the gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
Refuse a client whose IP is in a curated CIDR feed with 403 in the same
abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by
default.

- ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an
  allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the
  last-good feed on a transient fetch failure and drops it fail-open once stale
  (better to under-block than block a legitimate client on a frozen feed). A
  separate static CIDR set, not the per-IP fail2ban store.
- gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size
  cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}.
  IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot).
- observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana
  alert warns before the feed is dropped; service-overview panels.
- deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in
  via PROD_ vars).
- docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip,
  parser, fault-tolerance) + a 403 abuse-guard integration test.
2026-07-11 13:33:28 +02:00
developer ad1cc361e9 Merge pull request 'feat(bot): Telegram bot Bot API health over the bot-link (metrics + alerts)' (#245) from feature/bot-health-telemetry into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-11 10:58:01 +00:00
Ilia Denisov 2c2316fb5e chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange
values, grouped and price-sorted the same way projectOfferPricing lists them, so
the /catalog console mirrors what a buyer sees. Internal cosmetics only — no
product behaviour change. The value-group order moves to a shared helper
(valueGroup) so the offer and the admin list cannot drift.
2026-07-11 12:53:37 +02:00
Ilia Denisov bb71e7b1c7 feat(bot): report Telegram bot Bot API health to the gateway over the bot-link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bot runs on its own host and exports no telemetry (otelcol is unreachable
from there), so it was a monitoring blind spot. Observe its Bot API health
centrally by wrapping the HTTP client — one place, no per-call-site
instrumentation — and relay it up the existing bot-link as a periodic Health
message the gateway turns into its own metrics.

- proto: add Health (delta connect / api / 429 counters + a last-ok stamp) to
  the FromBot oneof (additive, backward-compatible).
- bot: platform/telegram/internal/health wraps the Bot API HTTP client — it
  stamps liveness on any 2xx (so the getUpdates long-poll keeps it fresh even
  when idle), classifies transport/5xx failures (getUpdates vs other) and 429s,
  and honours a 429's Retry-After (bounded) so the bot backs off; a 4xx other
  than 429 is a normal per-request outcome and is not counted.
- bot-link client: flush the reporter as a Health message every 30s over a
  single-sender loop (a gRPC stream forbids concurrent Send).
- gateway: fold each report into bot_tg_errors_total{kind} and the
  bot_tg_last_ok_unix liveness gauge.
- grafana: alerts (bot disconnected, bot not reaching the Bot API, sustained
  429s) routed to the operator email — which does not go through the bot — plus
  a Telegram-bot dashboard.
- docs (ARCHITECTURE, compose comments); unit tests (observer classification,
  Retry-After, snapshot/commit, hub last-ok monotonicity).
2026-07-11 12:48:06 +02:00
developer 5c1f64c7d1 Merge pull request 'feat(offer): live catalog price list in the public offer (render sidecar)' (#244) from feature/offer-pricing-live into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-11 09:51:48 +00:00
Ilia Denisov b3cf024e9e chore(offer): fix typo
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 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
2026-07-11 11:45:45 +02:00
Ilia Denisov 6e120bdaa7 docs(offer): update phrasing
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m24s
2026-07-11 11:39:43 +02:00
Ilia Denisov 40acbcccdd feat(offer): sort and align the §4.4 price tables, style them for both themes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
- packs sorted by ascending rouble price;
- values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament
  (the tournament group is empty until such products become sellable),
  ascending chip price within each group;
- price columns right-aligned (GFM "---:" separators);
- tables span the full content width; the name column shrinks to its content
  and never wraps;
- muted-but-visible cell borders on the dark theme, where the section rule
  colour blends into the background.
2026-07-11 11:29:41 +02:00
Ilia Denisov b54371845f harden(offer): escape admin product titles against HTML/markdown injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The offer price list projects the admin-entered product title into a markdown
table cell that is rendered, unsanitised, into the public /offer/ page. Escape
the title at the projection boundary so it renders as literal text: HTML
metacharacters become entities and the markdown table pipe and link brackets are
escaped, so a title can neither inject markup nor form a javascript: link. The
committed offer prose keeps its trusted-content treatment (code-reviewed, not
runtime input).
2026-07-10 20:30:36 +02:00
Ilia Denisov b6c2598710 feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer a241e43d79 Merge pull request 'Wallet redesign: split buy/spend, compact balance, exchange confirm + insufficient-chips fix' (#242) from feature/wallet-redesign into development
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
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 1m12s
CI / deploy (pull_request) Has been skipped
2026-07-10 16:00:54 +00:00
Ilia Denisov 8ce986922a feat(ui): wallet storefront redesign — split buy/spend, compact balance, exchange confirm
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Reworks the Wallet screen to be more compact and to separate the two flows, and fixes a
reported bug.

Bug: buying a value with too few chips showed the generic "Something went wrong" toast — the
backend's `insufficient_chips` (409) code was propagated correctly but had no i18n mapping, so
`errorKey` fell back to `error.generic`. Add `error.insufficient_chips` / `error.product_not_found`.
Also disable a value's Exchange button up front when the spendable balance cannot cover it (the
server stays authoritative).

Redesign:
- a compact **balance** row leads the screen — the context's own chips (🪙 N) then any linked
  other-platform chips behind that platform's logo (monospaced digits); inside a VK/TG store only
  that store's own segment shows;
- the benefits section is renamed **Active**, moved above the store, shows one inline line
  (hints + ad-free) and hides when nothing is active;
- the **store** splits by a two-way toggle into **Buy chips** (money packs + the watch-an-ad row at
  the top) and **Spend chips** (values, exchanged for chips);
- a value's action reads **Exchange** and opens a single confirmation dialog (chip spends are
  instant, with no provider window to confirm them); the existing cross-platform store-compliance
  warning folds into that same dialog when the spend would draw VK/TG chips.

No payments-model or wire change; the money→chips→values model is unchanged (verified: money buys
only chip packs, values are chips-only — "no-ads for money" is structurally impossible).

Tests: wallet e2e updated for the new layout (chromium + webkit green). Docs: FUNCTIONAL (+_ru)
wallet story rewritten. App bundle budget 126 → 127 KB (always-loaded settings hub).
2026-07-10 17:46:22 +02:00
developer 3469278260 Merge pull request 'fix(monitoring): don't false-fire the WAL-stalled alert on an idle database' (#241) from fix/pg-archive-stalled-idle-falsepositive into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m38s
2026-07-10 14:31:57 +00:00
Ilia Denisov c66bf1eceb fix(monitoring): pg_archive_stalled must not false-fire on an idle database
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The "WAL archiving stalled" alert fired on prod during a quiet period. An idle Postgres
archives nothing — it never force-switches an empty WAL segment on archive_timeout — so
pg_stat_archiver_last_archive_age grows past 30 min even though archiving is perfectly
healthy (failed_count 0, no .ready segments, pg_wal flat, backups current). A false
positive: no data and no disk at risk (nothing was written, so the frozen recovery point
equals the live state).

Gate the age condition on pg_wal actually growing:

  (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16MB)

so it fires only when WAL is being produced but not archived (the real "pg_wal fills the
disk" danger; genuine archive_command failures are already caught by pg_archive_failing).
`and on()` bridges the two metrics' differing label sets (last_archive_age carries a server
label, the pg_wal gauge does not); delta() (not increase) suits the gauge. pg_stat_wal is
not exported by this postgres_exporter, so pg_wal size growth is the available signal.
Validated on prod with promtool: the expression is empty while idle.

Also fix the runbook command in both archive alerts' annotations: `pgbackrest check` needs
`--pg1-user=scrabble`, or it connects as role "root" (which does not exist) and aborts with
"no database found".
2026-07-10 16:25:01 +02:00
developer 7b4d2421e2 Merge pull request 'In-game UX: board highlight + score badge, full-width rack, bag badge, zoom setting' (#240) from feature/ingame-ux-board-highlight into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m49s
2026-07-10 14:06:20 +00:00
Ilia Denisov bf46b9492d fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00
Ilia Denisov 1e1117c28e chore(ui): raise the app bundle budget to 126 KB for the in-game board UX
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
2026-07-10 15:03:30 +02:00
Ilia Denisov 77a690fcf6 feat(ui): move in-game status to the board — highlight, score badge, full-width rack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00
224 changed files with 11663 additions and 1427 deletions
+242
View File
@@ -0,0 +1,242 @@
# Agent field notes — scrabble-game
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
memory, not a spec — **verify any named file / flag / function against the current code before acting
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
## Codegen & build
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
reorder output. After running it, revert the churn on tables you did not touch and commit only your
table's change.
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
another flatc version.
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
`go build` / `go test`, not the editor squiggles.
- **go-jet type mapping:** SQL `numeric``float64`, `interval``string`. Never store money as
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
**int seconds**, not `interval`.
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
## Native Android build (Capacitor)
The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
`android-36.1` does NOT satisfy compileSdk 36.
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
(dodges the corepack flake).
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
## Wire / schema evolution (client ↔ gateway ↔ backend)
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
and breaks older readers.
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
then bumps it from the live event. Don't expect it on the shared broadcast.
## UI / Svelte 5
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
subscription. Rename (e.g. `view`).
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
`{' : '}`.
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
tokens (mirror `NewGame`'s `.invite`).
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
with `$state.snapshot(...)` before storing.
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
client-generated file by **copying it to the clipboard**.
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
(routes to `vk.com/away.php`). Verify on-device.
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
matrix; do not re-litigate it without new on-device facts.
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
glyph fallback for old rendering.
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
those live on the deployed contour, not in the e2e.
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
Subscribe stream, cosmetic, deliberately left as-is.
## Testing
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
intercepts Playwright taps.
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
plugin-config gotcha in wiring them).
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
or the service crash-loops on start.
## Deploy / test contour (operational)
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
PR, one deploy) so deploys don't clobber each other.
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
prerelease step in `PRERELEASE.md`.
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
locale.
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
bot-link silently dies. Diagnose from inside the netns.
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
socket-inspect trick (single-service recreate).
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
caddy; don't trust "deploy green" for an edge-config change.
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
to the landing catch-all. Add a CI probe for the route.
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
telemetry log or Tempo, not caddy.
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
`/assets/main-*.js`.
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
their pinned version. The **owner** does the upload.
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
re-run, it's not your code.
## Repo workflow
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
/ add a plan line — not file a tracker issue.
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
stale-mergeable trap (re-check mergeability right before merging).
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
case — watch the post-merge runs too.
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
local feature branch.
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
wedged contour can be recovered by recreating the host container set.
## Domain semantics (not obvious from the code)
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
owner on every deletion point before wiring it.
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
account row is created at the **code-request** step, not at confirmation.
- **The robot has two distinct time windows:** a **sleep window** (~00:0007:00, gates its moves and
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
**sleep** window.
## Production topology
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
the source of truth for IPs/roles).
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
owner-side step **before** a deploy, not after.
## Feature-area pointers (state lives in the linked docs/plans, not here)
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0E9). go-jet money rule above applies.
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
`--pg1-user=scrabble`.
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
offline-first bundles the dictionaries (see `ANDROID_PLAN.md`).
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
App, server-side confidential code exchange.
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
offline-first, RuStore).
@@ -0,0 +1,172 @@
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
#
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
# runner host needs nothing pre-installed.
name: android-build
run-name: "android build ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "build" to confirm an APK build from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
DICT_VERSION: ${{ vars.DICT_VERSION }}
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
VITE_PAYMENTS_DISABLED: "1"
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
# lives elsewhere (the default matches deploy/README.md's install path).
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
# missing — this doubles as the runner-access check (deploy/README.md).
- name: Verify the host Android SDK
run: |
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sm" ]; then
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
fi
for pkg in "platforms/android-36" "build-tools"; do
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
fi
done
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
- name: Compute version + native build env
id: prep
env:
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
run: |
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
# rather than derive a versionCode from a "-N-gSHA" describe.
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
case "$desc" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
esac
v="${desc#v}"
IFS=. read -r MA MI PA <<< "$v"
# 10# forces base-10 so a zero-padded part is never read as octal.
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
# trailing slash so the Connect endpoint never doubles it.
gateway="${PUBLIC_BASE_URL%/}"
{
echo "tag=$desc"
echo "name=$v"
echo "code=$code"
echo "gateway=$gateway"
} >> "$GITHUB_OUTPUT"
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build the SPA (native flavour)
working-directory: ui
env:
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
run: pnpm run build
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
# (after the build, before cap sync copies dist/ into the native assets).
- name: Bundle the dictionaries
working-directory: ui
env:
DICT_DIR: ${{ github.workspace }}/dawg
run: node scripts/bundle-dicts.mjs
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
- name: Sync the native project
working-directory: ui
run: node_modules/.bin/cap sync android
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
echo "keystore decoded -> signed release build"
else
echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
uses: actions/upload-artifact@v4
with:
name: erudit-${{ steps.prep.outputs.name }}-apk
path: ui/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
+17 -7
View File
@@ -353,6 +353,11 @@ jobs:
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
@@ -510,15 +515,20 @@ jobs:
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /offer/ is a static page baked into the landing image (rendered from
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
# silently falls through to the landing shell (also 200) — so assert offer-specific
# content, never just the status.
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
# the marker with nothing, so "pricing_template" must be absent either way.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742"; then
echo "ok: /offer/ serves the public offer page"
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
else
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
docker logs --tail 50 scrabble-renderer || true
docker logs --tail 50 scrabble-backend || true
docker logs --tail 50 scrabble-landing || true
exit 1
fi
+11
View File
@@ -112,9 +112,20 @@ jobs:
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes): one account for
+4
View File
@@ -70,6 +70,10 @@ jobs:
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
+1115
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -156,3 +156,11 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
## Agent field notes
Non-obvious, hard-won knowledge the agent has accumulated that is **not** captured in the docs above,
kept in the repo so it travels with a clone. Verify any named file/flag against current code before
acting on it.
@.claude/CLAUDE.md
+7
View File
@@ -283,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("payments domain ready")
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
// failure here only defers the projection to the first read.
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
}
// Wire the payments surface into the domains that consume it: the online-game hint wallet
// and the account-merge wallet fold. Done after the reachability check so a broken payments
// schema fails boot before anything depends on it.
@@ -8,6 +8,7 @@
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
<li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li>
+6 -2
View File
@@ -348,8 +348,12 @@ type GameDetailView struct {
UpdatedAt string
FinishedAt string
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool
Seats []SeatRow
VsAI bool
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
// is validated and scored), false = the single-word rule (only the main word along the play
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
MultipleWordsPerTurn bool
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool
@@ -4,6 +4,7 @@ package inttest
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
t.Error("deactivated product must not appear in the storefront")
}
}
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
// with its per-rail prices, and archiving it through the service drops it from the next read.
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
svc := newPaymentsService()
ctx := context.Background()
title := "OfferTest " + uuid.NewString()
id, err := svc.CreateProduct(ctx, payments.ProductInput{
Title: title,
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
Prices: []payments.PriceLine{
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
},
}, true)
if err != nil {
t.Fatalf("create pack: %v", err)
}
md, err := svc.OfferPricing(ctx)
if err != nil {
t.Fatalf("offer pricing: %v", err)
}
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
}
// Archiving through the service marks the cache stale; the next read must reproject without it.
if err := svc.SetProductActive(ctx, id, false); err != nil {
t.Fatalf("archive: %v", err)
}
md, err = svc.OfferPricing(ctx)
if err != nil {
t.Fatalf("offer pricing after archive: %v", err)
}
if strings.Contains(md, title) {
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
}
}
+82 -4
View File
@@ -1,9 +1,12 @@
package payments
import (
"cmp"
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"github.com/google/uuid"
@@ -147,13 +150,76 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx)
}
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
// products the same as active ones (the admin list shows both).
func SortAdminCatalog(products []AdminProduct) {
slices.SortStableFunc(products, func(a, b AdminProduct) int {
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
if pa {
return -1 // packs (sales) before values (chip exchange)
}
return 1
} else if pa {
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
}
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
})
}
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
func adminIsPack(p AdminProduct) bool {
for _, a := range p.Atoms {
if a.Atom == atomChips {
return true
}
}
return false
}
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
func adminValueGroup(p AdminProduct) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range p.Atoms {
switch a.Atom {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
for _, pr := range p.Prices {
if pr.Method == method && pr.Currency == cur {
return pr.Amount
}
}
return math.MaxInt64
}
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
// product created active must satisfy the sellable shape.
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
if err := validateProduct(in, active); err != nil {
return uuid.Nil, err
}
return s.store.createProduct(ctx, in, active, s.clock())
id, err := s.store.createProduct(ctx, in, active, s.clock())
if err == nil {
s.markOfferStale()
}
return id, err
}
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
@@ -166,7 +232,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
if err := validateProduct(in, active); err != nil {
return err
}
return s.store.updateProduct(ctx, id, in, s.clock())
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
@@ -181,11 +251,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
return err
}
}
return s.store.setProductActive(ctx, id, active, s.clock())
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
return s.store.deleteProduct(ctx, id)
if err := s.store.deleteProduct(ctx, id); err != nil {
return err
}
s.markOfferStale()
return nil
}
+44
View File
@@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) {
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
}
}
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
// ascending by chip price within a group. Stable and applied to active + archived alike.
func TestSortAdminCatalog(t *testing.T) {
pack := func(title string, rub int64) AdminProduct {
return AdminProduct{
Title: title,
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) AdminProduct {
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
for _, a := range atoms {
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
}
return p
}
products := []AdminProduct{
pack("packDear", 30000),
value("bundle", 500, "hints", "noads_days"),
pack("packCheap", 10000),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
SortAdminCatalog(products)
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
for i, p := range products {
if p.Title != want[i] {
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
}
}
}
// titles extracts the product titles for a failure message.
func titles(products []AdminProduct) []string {
out := make([]string, len(products))
for i, p := range products {
out[i] = p.Title
}
return out
}
+218
View File
@@ -0,0 +1,218 @@
package payments
import (
"cmp"
"context"
"fmt"
"math"
"slices"
"strings"
)
// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries
// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing]
// returns before rendering the /offer/ page; the backend only produces the tables, never the marker.
const pricingMarker = "<#pricing_template#>"
// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the
// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK
// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The
// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]),
// so a steady-state read issues no query — only the first read after an edit reprojects. The render
// sidecar fetches it and splices it into the offer markdown at the pricing marker.
func (s *Service) OfferPricing(ctx context.Context) (string, error) {
s.offerMu.Lock()
defer s.offerMu.Unlock()
if s.offerFresh {
return s.offerMD, nil
}
md, err := s.buildOfferPricing(ctx)
if err != nil {
return "", err
}
s.offerMD = md
s.offerFresh = true
return md, nil
}
// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing]
// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it.
func (s *Service) markOfferStale() {
s.offerMu.Lock()
s.offerFresh = false
s.offerMu.Unlock()
}
// buildOfferPricing loads the active catalog and projects it into the offer tables.
func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
entries, err := s.store.loadCatalog(ctx)
if err != nil {
return "", err
}
return projectOfferPricing(entries), nil
}
// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries
// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip
// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
func projectOfferPricing(entries []catalogEntry) string {
var packs, values []catalogEntry
for _, e := range entries {
if isPackEntry(e) {
packs = append(packs, e)
} else {
values = append(values, e)
}
}
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
})
slices.SortStableFunc(values, func(a, b catalogEntry) int {
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
return d
}
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
})
var b strings.Builder
if len(packs) > 0 {
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
b.WriteString("| --- | ---: | ---: | ---: |\n")
for _, e := range packs {
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
offerCell(e.title),
offerPrice(e, string(SourceDirect), CurrencyRUB),
offerPrice(e, string(SourceVK), CurrencyVote),
offerPrice(e, string(SourceTelegram), CurrencyStar),
)
}
}
if len(values) > 0 {
if len(packs) > 0 {
b.WriteString("\n")
}
b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
b.WriteString("| Наименование | «Фишки» |\n")
b.WriteString("| --- | ---: |\n")
for _, e := range values {
fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
}
}
return strings.TrimRight(b.String(), "\n")
}
// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
func offerValueGroup(e catalogEntry) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range e.atoms {
switch a.atomType {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
// the catalog shape forbids it).
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
switch {
case hasTournament:
return 3
case hasHints && hasNoAds:
return 2
case hasNoAds:
return 1
case hasHints:
return 0
default:
return 4
}
}
// offerSortAmount returns the entry's price in the given method and currency for ordering, or
// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading.
func offerSortAmount(e catalogEntry, method string, cur Currency) int64 {
if amt, ok := offerAmount(e, method, cur); ok {
return amt
}
return math.MaxInt64
}
// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds
// chips with money) rather than being a chip-priced value.
func isPackEntry(e catalogEntry) bool {
for _, a := range e.atoms {
if a.atomType == atomChips {
return true
}
}
return false
}
// offerPrice formats the entry's price for the given payment method and currency as a major-unit
// string, or an em dash when the entry carries no such price.
func offerPrice(e catalogEntry, method string, cur Currency) string {
amt, ok := offerAmount(e, method, cur)
if !ok {
return "—"
}
m, err := MoneyFromMinor(amt, cur)
if err != nil {
return "—"
}
return m.Major()
}
// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and
// currency, and whether such a price exists.
func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) {
for _, pr := range e.prices {
if pr.method == method && pr.currency == cur {
return pr.amount, true
}
}
return 0, false
}
// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows
// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not
// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the
// page), the markdown table pipe and the row newline, and the link brackets (a title must never
// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the
// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&amp;" does not
// double-escape the entities the other rules emit.
var offerCellReplacer = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
"|", `\|`,
"[", `\[`,
"]", `\]`,
"\n", " ",
)
// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of
// the public offer (see [offerCellReplacer]).
func offerCell(s string) string {
return offerCellReplacer.Replace(s)
}
+137
View File
@@ -0,0 +1,137 @@
package payments
import (
"strings"
"testing"
"github.com/google/uuid"
)
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
// value render into the two tables, pack table first, money formatted through Money.
func TestProjectOfferPricing(t *testing.T) {
entries := []catalogEntry{
{
id: uuid.New(),
title: "50 «Фишек»",
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
prices: []priceRow{
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
},
},
{
id: uuid.New(),
title: "200 подсказок",
atoms: []atomQty{{atomType: "hints", quantity: 200}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
},
}
md := projectOfferPricing(entries)
for _, want := range []string{
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
"| --- | ---: | ---: | ---: |", // price columns right-aligned
"| 50 «Фишек» | 200.00 | 30 | 100 |",
"| Наименование | «Фишки» |",
"| --- | ---: |",
"| 200 подсказок | 50 |",
} {
if !strings.Contains(md, want) {
t.Errorf("projection missing %q\n---\n%s", want, md)
}
}
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
t.Errorf("the pack table must precede the values table:\n%s", md)
}
}
// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by
// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group.
func TestProjectOfferPricingOrdering(t *testing.T) {
pack := func(title string, rub int64) catalogEntry {
return catalogEntry{
id: uuid.New(),
title: title,
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) catalogEntry {
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
for _, a := range atoms {
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
}
return e
}
// Deliberately out of order on input.
entries := []catalogEntry{
pack("packDear", 30000),
pack("packCheap", 10000),
value("bundle", 500, "hints", "noads_days"),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
md := projectOfferPricing(entries)
order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
last := -1
for _, title := range order {
i := strings.Index(md, "| "+title+" |")
if i < 0 {
t.Fatalf("row %q missing:\n%s", title, md)
}
if i < last {
t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md)
}
last = i
}
}
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
// title carrying a pipe is escaped so the table layout survives.
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: "Bonus | pack",
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
// A roubles price only — no VK, no Telegram.
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
}}
md := projectOfferPricing(entries)
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
t.Errorf("want row %q in:\n%s", want, md)
}
}
// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link
// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the
// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not
// survive into the projected markdown.
func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: `<script>alert(1)</script> [x](javascript:alert(2)) & "q"`,
atoms: []atomQty{{atomType: "hints", quantity: 1}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}},
}}
md := projectOfferPricing(entries)
for _, bad := range []string{"<script>", "</script>", "[x]", `& "q"`} {
if strings.Contains(md, bad) {
t.Errorf("unescaped %q survived into the projection:\n%s", bad, md)
}
}
for _, want := range []string{"&lt;script&gt;", `\[x\]`, "&amp;", "&quot;q&quot;"} {
if !strings.Contains(md, want) {
t.Errorf("want escaped %q in:\n%s", want, md)
}
}
}
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
// headers), so the offer's pricing marker is replaced with nothing.
func TestProjectOfferPricingEmpty(t *testing.T) {
if got := projectOfferPricing(nil); got != "" {
t.Errorf("empty catalog must project to empty string, got %q", got)
}
}
+8
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/google/uuid"
@@ -19,6 +20,13 @@ import (
type Service struct {
store *Store
clock func() time.Time
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
offerMu sync.Mutex
offerMD string
offerFresh bool
}
// NewService constructs a Service over store with a wall-clock time source.
+3
View File
@@ -74,6 +74,9 @@ func (s *Server) registerRoutes() {
u.POST("/wallet/buy", s.handleWalletBuy)
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
u.POST("/wallet/reward", s.handleWalletReward)
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
s.internal.GET("/offer/pricing", s.handleOfferPricing)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
@@ -41,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) {
s.consoleError(c, err)
return
}
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
payments.SortAdminCatalog(products)
var view adminconsole.CatalogView
for _, p := range products {
view.Products = append(view.Products, catalogRow(p))
@@ -566,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
}
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA.
@@ -112,6 +112,22 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
c.JSON(http.StatusOK, catalogDTOFrom(view))
}
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
// projected from the active products. The render sidecar fetches it and splices it into the offer
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
// the steady state). Called by the renderer, not the gateway.
func (s *Server) handleOfferPricing(c *gin.Context) {
md, err := s.payments.OfferPricing(c.Request.Context())
if err != nil {
s.log.Error("offer pricing projection failed", zap.Error(err))
c.String(http.StatusInternalServerError, "offer pricing unavailable")
return
}
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, md)
}
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
// unconfigured), which gates the client's "watch for chips" button.
+7
View File
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
# variables, shared across contours; in the test contour the stamped client version is a commit hash
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
GATEWAY_MIN_CLIENT_VERSION=
GATEWAY_RECOMMENDED_CLIENT_VERSION=
# --- VK Mini App ------------------------------------------------------------
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
+35 -2
View File
@@ -115,6 +115,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -233,6 +238,32 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
that survives losing the host.
## Android app build & release (RuStore)
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
**unsigned** release APK otherwise (a dry run still proves the pipeline).
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned**
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
the SDK is missing or unreadable.
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
variable to the store listing once published (empty until then — the in-app update button no-ops).
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
away cleanly instead of failing blind.
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
## Point-in-time recovery (PITR)
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
@@ -255,8 +286,10 @@ shipping or redeploying this stack does **not** start archiving — the artifact
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both
absent/NaN-safe, so they stay quiet until archiving is armed.
and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
— the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
archiving is armed.
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
+9
View File
@@ -107,6 +107,15 @@
}
}
# The public offer page is rendered by the render sidecar: it splices the live catalog price
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
# disjoint from the landing/app paths so the catch-all below never shadows it.
@offer path /offer /offer/*
handle @offer {
reverse_proxy renderer:8090
}
# Everything else — the public landing at / and any stray path — is static.
handle {
reverse_proxy landing:80
+6 -3
View File
@@ -3,8 +3,10 @@
# docker compose -f docker-compose.bot.yml up -d
#
# The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's
# published bot-link :9443 over mTLS. It exports no telemetry — otelcol lives on the
# main host and is unreachable from here — so observe it via `docker logs` on this host.
# published bot-link :9443 over mTLS. It exports no OTLP telemetry — otelcol lives on the
# main host and is unreachable from here — but it reports its Bot API health up the bot-link,
# which the gateway turns into metrics + alerts on the main host (docs/ARCHITECTURE.md); `docker
# logs` on this host is the local detail view.
# Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the
# pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443.
name: scrabble-bot
@@ -50,7 +52,8 @@ services:
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
# No telemetry export: otelcol is on the main host, unreachable from here.
# No OTLP export: otelcol is on the main host, unreachable from here. Bot API health rides the
# bot-link instead (the gateway exposes it as metrics); see docs/ARCHITECTURE.md.
TELEGRAM_OTEL_TRACES_EXPORTER: none
TELEGRAM_OTEL_METRICS_EXPORTER: none
GOMAXPROCS: "1"
+15
View File
@@ -84,6 +84,9 @@ services:
logging: *default-logging
environment:
RENDERER_PORT: "8090"
# The offer page (GET /offer/) fetches the live catalog price list from the backend's internal
# endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502.
RENDERER_BACKEND_URL: http://backend:8080
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
@@ -219,6 +222,12 @@ services:
GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
# Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
@@ -251,6 +260,12 @@ services:
# secret; empty (unset secret) leaves the trap off.
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
# Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible
# (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the
# prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults.
GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false}
GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-}
GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-}
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
GATEWAY_SERVICE_NAME: scrabble-gateway
GATEWAY_OTEL_TRACES_EXPORTER: otlp
+45
View File
@@ -0,0 +1,45 @@
{
"uid": "scrabble-bot",
"title": "Scrabble — Telegram bot",
"tags": ["scrabble"],
"timezone": "",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"type": "stat",
"title": "Bot connected",
"description": "botlink_connected_bots: bots currently holding the gateway bot-link (1 = healthy).",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(botlink_connected_bots)" }]
},
{
"type": "stat",
"title": "Last Bot API OK (age)",
"description": "Seconds since the bot's most recent successful Bot API call. Grows unbounded if the bot wedges.",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "time() - max(bot_tg_last_ok_unix)" }]
},
{
"type": "timeseries",
"title": "Bot API errors/s by kind",
"description": "bot_tg_errors_total by kind: connect (getUpdates), api (other sends), rate_limited (429). 429 should stay ~0.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum by (kind) (rate(bot_tg_errors_total[5m]))", "legendFormat": "{{kind}}" }]
},
{
"type": "timeseries",
"title": "Bot-link commands/s by result",
"description": "botlink_commands_total by result: delivered / not_delivered / dropped / error. Sustained not_delivered or error means gateway sends are failing to reach Telegram.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum by (result) (rate(botlink_commands_total[5m]))", "legendFormat": "{{result}}" }]
}
]
}
@@ -53,6 +53,31 @@
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
},
{
"type": "stat",
"title": "Blocklist entries",
"description": "CIDR ranges in the active community IP blocklist feed (0 when disabled or not yet fetched).",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 13 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_entries)" }]
},
{
"type": "stat",
"title": "Blocklist feed age",
"description": "Seconds since the community IP blocklist feed was last successfully fetched. Grows if the fetch is failing; the feed is dropped fail-open once stale.",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 13 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_age_seconds)" }]
},
{
"type": "timeseries",
"title": "Blocklist blocks/s",
"description": "Requests refused at the edge by the community IP blocklist (gateway_blocklist_blocked_total).",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(gateway_blocklist_blocked_total[5m]))" }]
}
]
}
+124 -4
View File
@@ -108,6 +108,32 @@ groups:
labels: { severity: critical }
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
# The community IP blocklist feed has not refreshed in over a day (the gateway re-fetches every
# few hours). Absent/NaN-safe: the age gauge appears only once a feed has loaded, so a disabled
# or never-fetched blocklist stays quiet. The feed itself is dropped (fail-open) at its
# max-staleness window; this warns well before that so the operator can fix the fetch.
- uid: blocklist_stale
title: IP blocklist feed not refreshing
condition: C
for: 15m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: gateway_blocklist_age_seconds, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [86400] }
labels: { severity: warning }
annotations: { summary: 'The community IP blocklist feed has not refreshed in over 24h — the fetch is failing; it will be dropped (fail-open) once stale. Check the gateway blocklist refresher logs and the feed URL.' }
- orgId: 1
name: scrabble-host
folder: Alerts
@@ -245,8 +271,14 @@ groups:
conditions:
- evaluator: { type: gt, params: [0] }
labels: { severity: critical }
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' }
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
# Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
# produced but not archived). A genuinely idle database archives nothing — Postgres does not
# force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
# during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
# label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
# increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
- uid: pg_archive_stalled
title: WAL archiving stalled
condition: C
@@ -255,11 +287,11 @@ groups:
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
relativeTimeRange: { from: 2100, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: pg_stat_archiver_last_archive_age
expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
instant: true
- refId: C
datasourceUid: __expr__
@@ -270,4 +302,92 @@ groups:
conditions:
- evaluator: { type: gt, params: [1800] }
labels: { severity: critical }
annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' }
annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
# Remote Telegram bot health. The bot runs on its own host and exports no telemetry of its own; the
# gateway observes it through the bot-link (botlink_connected_bots) and the health it reports over
# that stream (bot_tg_*). All absent/NaN-safe: before a bot ever connects the metrics are absent, so
# noDataState OK keeps them quiet on a fresh contour. The alert email does NOT go through the bot, so
# a bot-down alert is deliverable.
- orgId: 1
name: scrabble-bot
folder: Alerts
interval: 1m
rules:
- uid: bot_disconnected
title: Telegram bot disconnected
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: botlink_connected_bots, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [1] }
labels: { severity: critical }
annotations: { summary: 'No Telegram bot is connected to the gateway bot-link — out-of-app push and admin sends are down. Check the bot host.' }
# Positive liveness: a bot is connected but its last successful Bot API call is over 5 minutes
# old — the getUpdates long-poll returns every ~minute even when idle, so a stale stamp means the
# bot is wedged (no errors, no traffic). Guarded by "and connected" so a mere disconnect (owned by
# bot_disconnected) does not double-fire.
- uid: bot_tg_stale
title: Telegram bot not reaching the Bot API
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: (time() - bot_tg_last_ok_unix) and on() (botlink_connected_bots >= 1)
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [300] }
labels: { severity: critical }
annotations: { summary: 'A connected Telegram bot has not reached the Bot API in over 5 minutes — the update poll is wedged. Check the bot host and Telegram reachability.' }
# 429s should be ~never once the bot honours Retry-After; any sustained rate-limiting is a symptom
# (a send loop, a misbehaving path) worth investigating.
- uid: bot_tg_rate_limited
title: Telegram bot rate-limited (429)
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: increase(bot_tg_errors_total{kind="rate_limited"}[15m])
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0] }
labels: { severity: warning }
annotations: { summary: 'The Telegram bot is being rate-limited (HTTP 429). It should honour Retry-After, so sustained 429s point to a send loop or a hot path.' }
+3 -12
View File
@@ -18,18 +18,9 @@
@shell not path /assets/*
header @shell Cache-Control "no-cache"
# The static public offer page, rendered from ui/legal/offer_ru.md at build
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
# landing shell below (whose index is landing.html). A bare /offer redirects in.
handle /offer {
redir * /offer/ permanent
}
handle /offer/* {
file_server {
index index.html
}
}
# The public offer page (/offer/) is no longer served here: the contour caddy routes it to the
# render sidecar, which splices the live catalog price list into ui/legal/offer_ru.md. This
# container never sees /offer/, so it carries no offer assets (the vite emit-offer plugin is gone).
# An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below.
+7
View File
@@ -49,6 +49,8 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$APP_VERSION'
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
@@ -77,6 +79,11 @@ export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
export GATEWAY_ABUSE_BAN_ENABLED='true'
# Community IP blocklist (Spamhaus DROP): opt-in — the operator enables it and sets the feed URL +
# allowlist via PROD_GATEWAY_BLOCKLIST_* vars once the feed is verified. Unset ⇒ off (safe).
export GATEWAY_BLOCKLIST_ENABLED='${GATEWAY_BLOCKLIST_ENABLED:-false}'
export GATEWAY_BLOCKLIST_URL='$GATEWAY_BLOCKLIST_URL'
export GATEWAY_BLOCKLIST_ALLOW='$GATEWAY_BLOCKLIST_ALLOW'
# Continuous WAL archiving (pgBackRest -> S3) for point-in-time recovery. The artifact
# ships disarmed: PGBACKREST_ARCHIVE_MODE defaults off, so archiving stays inert until the
# operator sets the S3 repository values + secrets, creates the stanza and flips the switch
+162 -42
View File
@@ -100,15 +100,26 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes. The client treats a connectivity edge failure as
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
response was lost — its button is disabled while offline and the player re-issues it on
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
**Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -140,6 +151,40 @@ dropped). Horizontal scaling is explicit future work.
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
client's in-app stream while the app is open. Out-of-app delivery uses
platform-native push via the platform side-service.
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
vars empty ⇒ off, web behaviour unchanged).
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
`offlineVersionLocked` (offline mode) and shows a **dismissable notice****"Update"** (native → the
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **`min`**
(validated at config load); empty ⇒ the soft tier is off.
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
rollout (see [`../deploy/README.md`](../deploy/README.md)).
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
rather than interrupting local play.
## 3. Authentication & sessions
@@ -224,6 +269,19 @@ arrive from a platform rather than completing a mandatory registration).
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
Platform and email users are auto-provisioned **durable** accounts with an
identity.
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
prior online-session rule.
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
> two-bot model (one bot per service language, with `accounts.service_language`, a
@@ -435,7 +493,11 @@ Key points:
through a **session-gated `GET /dict/{variant}/{version}`** edge route
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
miss, storage eviction or a bad-connection breaker falls back to the network
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
tiles, so it works regardless of which eval path produced the preview (the badge's number still
comes from the preview's score): legality and score stay with the eval, geometry with the board.
## 6. Game rules
@@ -905,6 +967,22 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
image option is not offered.
The same sidecar also serves the **public offer page** at `/offer/` — the one edge-exposed
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it
fetches the two catalog tables as markdown from the backend's internal
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served
render issues no query in the steady state and the page always reflects the current catalog without
a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. Packs are
ordered by ascending rouble price; values are grouped by what they grant — hints only, then no-ads
only, then no-ads + hints, then (reserved, empty today) products carrying the **tournament** atom,
which becomes a fourth group once the tournament economy makes them sellable — and ordered by
ascending chip price within each group. Product titles are admin input, so the projection escapes
them (HTML entities + markdown metacharacters) before they reach the un-sanitised renderer.
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
@@ -995,6 +1073,20 @@ answering `/start` with a URL button into the **main** bot's Mini App (`?startap
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
Because the bot **exports no telemetry of its own** (the OTel collector is on the main host,
unreachable from the bot host), it reports its **Bot API health** up the same stream: a periodic
`Health` message (`platform/telegram/internal/health`) carries delta counts of connect failures (the
getUpdates long-poll), other API errors and 429s, plus the wall-clock second of its last successful
Bot API call. The bot observes these **centrally by wrapping the Bot API HTTP client** — one place,
no per-call-site instrumentation — and there also honours a 429's `Retry-After` (bounded) so it backs
off rather than hammering (a 429 should therefore stay ~0). The gateway folds each report into its
own metrics (`bot_tg_errors_total{kind}`, the `bot_tg_last_ok_unix` liveness gauge). The remote bot
is thus monitored from the main host's Grafana with three layered signals: the **connection gauge**
(`botlink_connected_bots`) catches a link/host/process outage, the **liveness gauge** catches a
silently wedged bot (its stamp stays fresh even when idle, since getUpdates returns every poll), and
**`botlink_commands_total{result}`** catches gateway sends failing to reach Telegram. Alerts route to
the operator email, which does not depend on the bot.
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
@@ -1053,7 +1145,9 @@ link — misses the event; while an add-email confirmation is pending the client
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
and the gateway↔validator and bot-link calls; the gateway also exports
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link. The OTLP **Collector** (OTLP/gRPC → Prometheus
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link, plus the remote
bot's own Bot API health it relays over the stream — `bot_tg_errors_total` (by kind) and the
`bot_tg_last_ok_unix` liveness gauge (see the bot-link section). The OTLP **Collector** (OTLP/gRPC → Prometheus
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
are stood up with the deploy (`deploy/`); the default exporter stays
@@ -1170,6 +1264,18 @@ link — misses the event; while an add-email confirmation is pending the client
(`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies
the operator unbans the response returns, so a manual unban takes effect within the sync
interval.
- **Community IP blocklist (prod-only):** with `GATEWAY_BLOCKLIST_ENABLED` set, the gateway also
refuses a client whose IP is in a curated CIDR feed (Spamhaus DROP, `GATEWAY_BLOCKLIST_URL`) with
**403** in the same `abuseGuard`, before the fail2ban list. A background refresher re-fetches the
feed every few hours (bounded fetch + size cap) into a sorted-range matcher (`ratelimit.Blocklist`,
binary search, IPv4 only — an IPv6 client is never blocked here). It is **fault-tolerant and
fail-open**: a failed fetch keeps the last-good feed, and once the feed is older than
`GATEWAY_BLOCKLIST_MAX_STALENESS` it is **dropped** rather than block a legitimate client on a
frozen list; a `GATEWAY_BLOCKLIST_ALLOW` allowlist (own infra, monitoring) is never blocked. Off by
default and prod-only for the same real-client-IP reason as the ban. It is a **separate** static
CIDR set, not the per-IP fail2ban store (a bulk CIDR feed cannot expand into per-IP entries).
Observability: `gateway_blocklist_blocked_total`, the `gateway_blocklist_entries` size gauge and the
`gateway_blocklist_age_seconds` staleness gauge (a Grafana alert warns before the feed is dropped).
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
database answers a bounded ping and the session cache is warmed).
- The backend serves a **second listener** — a gRPC server
@@ -1284,13 +1390,18 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
hotseat create is guarded when
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
the in-browser engine and driven by the same game screen, the robot replying locally.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
@@ -1318,36 +1429,27 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
mode** boots from the persisted session and
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the `.webmanifest` MIME type
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/`
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
container. The
**Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
@@ -1419,6 +1521,24 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
contour activates; the prod main host omits it.
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
the cache). It is **offline-first**:
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook:
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
## 14. CI & branches
- **Two long-lived branches**: **`development`** is the integration
+82 -41
View File
@@ -30,7 +30,11 @@ render with arbitrary languages, so detection would make the indexed content
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page.
shell is `noindex` — the landing is the only indexable page. The footer links to the **public
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
the published prices always match what is currently on sale — no redeploy to update them.
On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
@@ -204,13 +208,17 @@ client, and refuses a guest's friend request, friend code or invitation outright
### Playing a game
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
bag are shown as a count on that control and at the foot of the move table. Tiles are laid
without choosing a direction — the game infers the play's orientation, so a single
tile that extends
an existing word (down a column or across a row) is accepted. A play is validated
against the game's dictionary at submit time and scored; an unlimited preview
reports the word(s) a tentative move would form and its score, or that it is not
legal, and the move is offered for submission only once it is confirmed legal. The preview is
against the game's dictionary at submit time and scored; an unlimited preview shows
**on the board itself** whether the tentative move is legal — the staged tiles turn a light
green once they form a word, a shade darker on the board tiles the word runs through (in a
one-word-per-turn game only the main word), or a calm pink when they form none — and an
**orange badge** on the main word carries the move's
score. The confirm control is offered only once the move is legal. The preview is
computed **on-device** for an instant response once the game's dictionary has loaded — a
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
local dictionary is unavailable. The dictionary check tool is
@@ -269,22 +277,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics.
### Offline mode
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
A New Game against the robot creates a device-local **vs_ai** game — it
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
offline mode, and never sync to the account. So a game can be created and played with no connection,
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
native apps only). When it cannot reach the
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
app never touches the network.
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
The **lobby stays unified**: your device-local games are active, while your server games are still
listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
backend; local games are kept on the device and never sync to the account, so a game can be created and
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
while still online, and (once installed, or on the native app) launches from bundled/precached data even
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
a short note.
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
@@ -297,14 +311,31 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
with no connection it switches to offline for that session; when the device is online but the gateway
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
the network — airplane mode — switches to offline automatically, and restoring it returns online by
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
dialog — is the player's choice: it is kept, and never undone automatically.
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
offline, so you are never interrupted for a hiccup.
### Staying up to date
When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
required — version exists; you can update or dismiss it, and play continues either way.
### Native app (Android)
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
network is available the app quietly establishes a guest in the background and online features (random
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
this appears only on an online action, never while you are playing offline.
### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six
@@ -372,7 +403,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
entry that opens this screen, and your display preferences (theme, board-label style and
reduce-motion — not the interface language, which follows your account) sync across your
Telegram devices.
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
@@ -528,21 +561,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
### Wallet
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
or the web), their **active benefits** (ads off until a date or forever, and the available hint
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
shown but frozen for spending.
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
the web, split by where they were funded. What is visible and spendable depends on **where the app
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
VK-iOS the balance is shown but frozen for spending.
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
price shown in every context, and **chip packs** bought with real money, priced in the running
context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
**no purchase history** — only the current balances, benefits and store.
**no purchase history** — only the current balances, active benefits and store.
### Advertising banner
+87 -43
View File
@@ -32,7 +32,12 @@ top-1 подсказку, безлимитную проверку слова с
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница.
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
сейчас в продаже — без передеплоя.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
@@ -212,13 +217,17 @@ e-mail) либо ввод фразы. Активные игры форфейтя
### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
уже лежащее
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
@@ -274,22 +283,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
приложениям). Когда оно не может достучаться
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
сети.
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -303,14 +317,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
вас не прерывают из-за короткой икоты.
### Актуальная версия
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
офлайн-игры.
### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
@@ -381,7 +413,10 @@ UTC; при создании аккаунта она подставляется
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram.
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
где доска и так помещается целиком и авто-зума нет.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
@@ -541,21 +576,30 @@ high-rate флага. С карточки пользователя операт
### Кошелёк
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
Оплата открывает страницу провайдера (с принятием публичной оферты).
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
приложении.
В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
### Рекламный баннер
+37 -5
View File
@@ -17,21 +17,43 @@ tests or touching CI.
- **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/copy/download choice, plus Playwright specs against the
derivation, the GCG share/copy/download choice and the **net-state reducer**
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted). The
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
end-to-end: it forces the installed-PWA display mode, enters offline through the
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
rack is deterministic and the human can tap out a precomputed opening), asserting the
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
production build.
production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
**unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
update_required` mapping.
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
minimum.
- **Render sidecar**`renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG
@@ -175,6 +197,16 @@ tests or touching CI.
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
lights up online play (a server guest is established); Profile offers **email** sign-in but no
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
## Principles
+33 -20
View File
@@ -179,16 +179,16 @@ e2e and the screenshots.
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
the first time — both gated by the **Zoom the board** setting (on by default; the toggle is
shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
that once retired this gesture) — and it is suppressed on the zoomed board, where the
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
opens on a **tap of the score bar** and closes on a tap or
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
hint's placement, not
the top-left.
an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms
out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
@@ -224,7 +224,7 @@ e2e and the screenshots.
are pinned to the card's edges.
Unread chat is also badged on the score bar itself, so it shows with the history closed.
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
@@ -235,23 +235,32 @@ e2e and the screenshots.
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
bottom: the score plaques, the **always-open** history (docked and scrolling with its header
sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score
preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board
bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and
scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer,
no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at
the bottom, the controls tab bar. Board
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
scroll is set directly, without the portrait width-progress tween). Only the history open/close
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather
than the viewport width so seven tiles never overflow the column.
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
the fixed 7th slot while a play is staged).
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
green once they form a word (a shade darker on the committed board tiles the word runs through —
in a **single-word game** only the main word lights up, since the engine ignores cross words),
a calm pink when they form none — and an **orange score badge** sits on a corner of the main
word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`,
`--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a
pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The
last completed word keeps the normal tile background; instead its letters — not the point
values — are drawn in the recent-move colour, in both themes. It is static while it is the
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
and the recent-move colour) when it is our turn (their word). While placing, only the
pending tiles are highlighted.
and the recent-move colour) when it is our turn (their word).
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
@@ -267,18 +276,22 @@ e2e and the screenshots.
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
button) beside the rack commits the move — no popover, and disabled while the pending word
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick
- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a
tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no
popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by
a **↩️ Reset** tab.
- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty;
opens a dialog — pick tiles to **Exchange N**, or pick
none to **Pass without exchanging**; pass is always available on your turn, exchange only
while the bag still holds a full rack, below which the tiles disable and only the pass
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
above the score plaques** reads, while composing a legal play, the formed word(s) and the move
score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
move score also rides the board badge (above), never an under-board caption.
The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
+8 -3
View File
@@ -42,10 +42,15 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first (the workspace file carries pnpm's build-script
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
# Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
# --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
# installs (where android:assets does need sharp built).
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
RUN pnpm install --frozen-lockfile --ignore-scripts
COPY ui ./
RUN pnpm build
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/ratelimit"
)
const (
// blocklistFetchTimeout bounds one feed fetch.
blocklistFetchTimeout = 30 * time.Second
// maxBlocklistBytes caps the fetched feed (Spamhaus DROP is well under 1 MiB; the cap stops a
// hostile or misconfigured URL from streaming unbounded data into memory).
maxBlocklistBytes = 8 << 20
)
// parseAllowlist parses the never-block entries (CIDRs or bare IPs, a bare IP read as a /32) into
// networks. A malformed entry is a fatal config error — the operator must fix it, not silently lose
// a protected range.
func parseAllowlist(entries []string) ([]*net.IPNet, error) {
out := make([]*net.IPNet, 0, len(entries))
for _, e := range entries {
s := strings.TrimSpace(e)
if s == "" {
continue
}
if !strings.Contains(s, "/") {
s += "/32"
}
_, n, err := net.ParseCIDR(s)
if err != nil {
return nil, fmt.Errorf("gateway: GATEWAY_BLOCKLIST_ALLOW: %q: %w", e, err)
}
out = append(out, n)
}
return out, nil
}
// runBlocklistRefresher keeps the community IP blocklist feed current: it fetches immediately, then
// every cfg.Refresh, and applies each outcome through ratelimit.ApplyRefresh (keep-last-good on a
// transient failure; drop the feed once it goes stale, fail-open). It returns when ctx is cancelled.
func runBlocklistRefresher(ctx context.Context, bl *ratelimit.Blocklist, cfg config.BlocklistConfig, log *zap.Logger) {
client := &http.Client{Timeout: blocklistFetchTimeout}
for {
cidrs, err := fetchBlocklist(ctx, client, cfg.URL)
switch ratelimit.ApplyRefresh(bl, cidrs, err, time.Now(), cfg.MaxStaleness) {
case ratelimit.RefreshUpdated:
log.Info("blocklist refreshed", zap.String("url", cfg.URL), zap.Int("entries", bl.Len()))
case ratelimit.RefreshKept:
log.Warn("blocklist refresh failed; keeping the last-good feed", zap.Error(err))
case ratelimit.RefreshDropped:
log.Warn("blocklist refresh failed and the feed is stale; dropped it (fail-open)", zap.Error(err))
}
select {
case <-ctx.Done():
return
case <-time.After(cfg.Refresh):
}
}
}
// fetchBlocklist GETs the feed and parses it, bounded by the fetch timeout and the size cap.
func fetchBlocklist(ctx context.Context, client *http.Client, url string) ([]*net.IPNet, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gateway: blocklist fetch %s: status %d", url, resp.StatusCode)
}
return ratelimit.ParseDROP(io.LimitReader(resp.Body, maxBlocklistBytes))
}
+27 -15
View File
@@ -129,6 +129,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Window: cfg.Abuse.BanWindow,
Duration: cfg.Abuse.BanDuration,
})
allow, err := parseAllowlist(cfg.Blocklist.Allow)
if err != nil {
return err
}
blocklist := ratelimit.NewBlocklist(cfg.Blocklist.Enabled, allow)
hub := push.NewHub(0)
var validator transcode.TelegramValidator
@@ -216,21 +221,24 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes,
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes,
MinClientVersion: cfg.MinClientVersion,
RecommendedClientVersion: cfg.RecommendedClientVersion,
})
// Bridge the backend push stream into the fan-out hub (and the out-of-app
@@ -243,6 +251,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
if cfg.Abuse.BanEnabled {
go runBanSync(ctx, banlist, backend, logger)
}
// When the edge blocklist is enabled (prod), keep the community feed refreshed.
if cfg.Blocklist.Enabled {
go runBlocklistRefresher(ctx, blocklist, cfg.Blocklist, logger)
}
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
servers := []*namedServer{{name: "public", srv: public}}
+48
View File
@@ -76,6 +76,11 @@ type Hub struct {
connected metric.Int64UpDownCounter
commands metric.Int64Counter
// tgErrors counts the remote bot's Bot API failures it reports over the stream, by kind
// (connect / api / rate_limited). lastOK holds the wall-clock second of the bot's most recent
// successful Bot API call, read by the bot_tg_last_ok_unix observable gauge for a staleness alert.
tgErrors metric.Int64Counter
lastOK atomic.Int64
}
// link is one connected bot's outbound queue and identity.
@@ -103,6 +108,19 @@ func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *H
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
h.commands, _ = meter.Int64Counter("botlink_commands_total",
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
h.tgErrors, _ = meter.Int64Counter("bot_tg_errors_total",
metric.WithDescription("Telegram Bot API failures the remote bot reports over the bot-link, by kind (connect, api, rate_limited)."))
// The bot's last successful Bot API second, reported over the stream. An observable gauge
// reads the atomic on each collection; it observes nothing until a bot has reported, so a
// never-connected gateway shows no data (the connected-bots alert covers that case).
_, _ = meter.Int64ObservableGauge("bot_tg_last_ok_unix",
metric.WithDescription("Unix second of the remote bot's most recent successful Bot API call (0/absent until reported)."),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
if v := h.lastOK.Load(); v > 0 {
o.Observe(v)
}
return nil
}))
}
return h
}
@@ -149,6 +167,36 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.
if ack := msg.GetAck(); ack != nil {
h.resolve(ack)
}
if hb := msg.GetHealth(); hb != nil {
h.recordHealth(hb)
}
}
}
// recordHealth folds one bot Health report into the gateway's cumulative Bot API metrics: the three
// delta counters are added under their kind label, and the last-ok stamp (an absolute wall-clock
// second) advances the liveness gauge (monotonically — a stale reordered report cannot rewind it).
func (h *Hub) recordHealth(hb *botlinkv1.Health) {
if h.tgErrors != nil {
ctx := context.Background()
if v := hb.GetConnectFailures(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "connect")))
}
if v := hb.GetApiErrors(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "api")))
}
if v := hb.GetRateLimited(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "rate_limited")))
}
}
for {
cur := h.lastOK.Load()
if hb.GetLastOkUnix() <= cur {
break
}
if h.lastOK.CompareAndSwap(cur, hb.GetLastOkUnix()) {
break
}
}
}
+18
View File
@@ -233,3 +233,21 @@ func TestRelayServerNoBot(t *testing.T) {
t.Fatal("expected an error with no bot connected")
}
}
// TestRecordHealthLastOKMonotonic checks a bot Health report advances the last-ok liveness stamp but
// a stale or reordered report (an earlier second) never rewinds it.
func TestRecordHealthLastOKMonotonic(t *testing.T) {
hub := NewHub(nil, nil, nil)
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 100})
if got := hub.lastOK.Load(); got != 100 {
t.Fatalf("lastOK = %d, want 100", got)
}
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 90}) // reordered/stale — must not rewind
if got := hub.lastOK.Load(); got != 100 {
t.Errorf("lastOK rewound to %d, want 100", got)
}
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 150})
if got := hub.lastOK.Load(); got != 150 {
t.Errorf("lastOK = %d, want 150", got)
}
}
+57
View File
@@ -0,0 +1,57 @@
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
// version string so the edge can turn away a build too old to speak the current wire
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
// (the caller fails open — an absent or garbled header is never treated as too old).
package clientver
import (
"strconv"
"strings"
)
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
// ordering; any pre-release or build suffix is dropped at parse time.
type Version struct {
Major, Minor, Patch int
}
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
// reports ok=false when s has fewer than three numeric components or any component is not
// an integer, so the caller can distinguish a real version from a dev/empty string.
func Parse(s string) (Version, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if i := strings.IndexAny(s, "-+"); i >= 0 {
s = s[:i]
}
p := strings.SplitN(s, ".", 4)
if len(p) < 3 {
return Version{}, false
}
var v Version
var err error
if v.Major, err = strconv.Atoi(p[0]); err != nil {
return Version{}, false
}
if v.Minor, err = strconv.Atoi(p[1]); err != nil {
return Version{}, false
}
if v.Patch, err = strconv.Atoi(p[2]); err != nil {
return Version{}, false
}
return v, true
}
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
// are not Less than each other, so a client exactly at the minimum passes the gate.
func Less(a, b Version) bool {
if a.Major != b.Major {
return a.Major < b.Major
}
if a.Minor != b.Minor {
return a.Minor < b.Minor
}
return a.Patch < b.Patch
}
@@ -0,0 +1,63 @@
package clientver
import "testing"
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
// gate fails open.
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
want Version
wantK bool
}{
{"plain", "1.16.0", Version{1, 16, 0}, true},
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
{"dev", "dev", Version{}, false},
{"empty", "", Version{}, false},
{"too few components", "1.16", Version{}, false},
{"non-numeric patch", "1.16.x", Version{}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := Parse(tc.in)
if ok != tc.wantK {
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
}
if ok && got != tc.want {
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
}
})
}
}
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
// is not Less (a client exactly at the minimum passes the gate).
func TestLess(t *testing.T) {
tests := []struct {
name string
a, b Version
want bool
}{
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Less(tc.a, tc.b); got != tc.want {
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
+107 -8
View File
@@ -6,8 +6,10 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry"
)
@@ -53,10 +55,22 @@ type Config struct {
// MaxBodyBytes caps one inbound request body on the public listener and one
// Connect message read; oversized requests are refused without buffering.
MaxBodyBytes int
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
// the edge serves. A client reporting an older X-Client-Version is turned away with an
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments.
MinClientVersion string
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
RecommendedClientVersion string
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
Abuse AbuseConfig
// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
Blocklist BlocklistConfig
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config
}
@@ -166,6 +180,42 @@ func DefaultAbuse() AbuseConfig {
}
}
// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
// test contour). Only IPv4 is matched.
type BlocklistConfig struct {
// Enabled turns edge blocklisting on. Off by default (prod-only).
Enabled bool
// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
// operator sets it explicitly so no feed URL is assumed.
URL string
// Refresh is how often the feed is re-fetched.
Refresh time.Duration
// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
// (fail-open — a legitimate client is never blocked on a frozen feed).
MaxStaleness time.Duration
// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
// the feed can never block. Parsed at startup.
Allow []string
}
// Blocklist defaults; the feed URL has no default (the operator sets it).
const (
defaultBlocklistRefresh = 6 * time.Hour
defaultBlocklistMaxStaleness = 48 * time.Hour
)
// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
// the agreed refresh / staleness windows.
func DefaultBlocklist() BlocklistConfig {
return BlocklistConfig{
Enabled: false,
Refresh: defaultBlocklistRefresh,
MaxStaleness: defaultBlocklistMaxStaleness,
}
}
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
@@ -187,14 +237,16 @@ func (c VKIDConfig) Enabled() bool {
func Load() (Config, error) {
var err error
c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_CLIENT_VERSION"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -203,6 +255,7 @@ func Load() (Config, error) {
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
Blocklist: DefaultBlocklist(),
BotLink: BotLinkConfig{
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
@@ -244,6 +297,17 @@ func Load() (Config, error) {
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
return Config{}, err
}
if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
return Config{}, err
}
c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
return Config{}, err
}
if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
return Config{}, err
}
c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
return Config{}, err
}
@@ -281,9 +345,29 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
}
if c.MinClientVersion != "" {
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
}
}
if c.RecommendedClientVersion != "" {
rec, ok := clientver.Parse(c.RecommendedClientVersion)
if !ok {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
}
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
// misconfiguration (every client below min is already turned away). An empty/unparseable
// minimum imposes no lower bound, so the recommended may stand alone.
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED")
}
if c.BotLink.Addr != "" {
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
@@ -304,6 +388,21 @@ func envOr(key, fallback string) string {
return fallback
}
// splitList splits a comma-separated environment value into trimmed, non-empty items.
func splitList(v string) []string {
if v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// envBool parses the environment variable named key as a bool, returning fallback
// when it is unset and an error when it is set but malformed.
func envBool(key string, fallback bool) (bool, error) {
+59
View File
@@ -47,6 +47,65 @@ func TestLoadMaxBodyBytes(t *testing.T) {
}
}
// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
// default, a parseable version accepted, and an unparseable one rejected.
func TestLoadMinClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.MinClientVersion != "" {
t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid min version: %v", err)
}
if c.MinClientVersion != "v1.16.0" {
t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
}
}
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
// rejected, and a recommended below the minimum rejected.
func TestLoadRecommendedClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.RecommendedClientVersion != "" {
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
}
// Stands alone with no minimum configured.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid recommended version (no min): %v", err)
}
if c.RecommendedClientVersion != "v1.20.0" {
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
}
// At or above the minimum is accepted.
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if _, err = Load(); err != nil {
t.Fatalf("Load with recommended >= min: %v", err)
}
// Below the minimum is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
}
// Unparseable is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) {
+31 -7
View File
@@ -2,6 +2,7 @@ package connectsrv_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
@@ -24,7 +25,7 @@ const honeypotHeader = "X-Scrabble-Honeypot"
// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake
// backend, returning the front URL, a Connect client and a cleanup func.
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -36,6 +37,7 @@ func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Banlist: bl,
Blocklist: block,
Honeytoken: honeytoken,
Hub: push.NewHub(0),
RateLimit: limits,
@@ -69,7 +71,7 @@ func enabledBanlist(threshold int) *ratelimit.Banlist {
func TestAbuseGuardBlocksBannedIP(t *testing.T) {
bl := enabledBanlist(100)
bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire)
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
@@ -86,7 +88,7 @@ func TestAbuseGuardBlocksBannedIP(t *testing.T) {
// and bans the client IP, so the next request is blocked.
func TestHoneypotHeaderTrips(t *testing.T) {
bl := enabledBanlist(100)
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for a honeypot hit")
})
defer cleanup()
@@ -118,7 +120,7 @@ func TestHoneypotHeaderTrips(t *testing.T) {
// banlist still 404s the decoy (detection/logging) but bans nothing.
func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) {
bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
@@ -150,7 +152,7 @@ func TestPublicRejectionStrikesBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.PublicPerMinute, limits.PublicBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
@@ -173,7 +175,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.UserPerMinute, limits.UserBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/internal/sessions/resolve":
_, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`))
@@ -202,7 +204,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
// caller and returns the ordinary invalid-session error without a backend call.
func TestHoneytokenBansAndRejects(t *testing.T) {
bl := enabledBanlist(100)
_, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, nil, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for the honeytoken")
})
defer cleanup()
@@ -217,3 +219,25 @@ func TestHoneytokenBansAndRejects(t *testing.T) {
t.Fatal("the honeytoken must ban the caller")
}
}
// TestAbuseGuardBlocksBlocklistedIP verifies a client IP in the community blocklist feed is refused
// with 403 at the HTTP layer, before any handler runs.
func TestAbuseGuardBlocksBlocklistedIP(t *testing.T) {
block := ratelimit.NewBlocklist(true, nil)
_, feed, err := net.ParseCIDR("127.0.0.0/8")
if err != nil {
t.Fatal(err)
}
block.SetCIDRs([]*net.IPNet{feed}, time.Now())
url, _, cleanup := guardedEdge(t, nil, block, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
if err != nil {
t.Fatalf("get: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("blocklisted GET / = %d, want 403", resp.StatusCode)
}
}
+1
View File
@@ -12,6 +12,7 @@ var (
errInternal = errors.New("internal error")
errMissingToken = errors.New("missing session token")
errInvalidSession = errors.New("invalid or expired session")
errUpdateRequired = errors.New("client too old, update required")
)
// errUnknownMessageType reports an unregistered message type.
+30 -1
View File
@@ -7,6 +7,8 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
"scrabble/gateway/internal/ratelimit"
)
// meterName scopes the gateway edge's OpenTelemetry instruments.
@@ -27,6 +29,7 @@ type serverMetrics struct {
edge metric.Float64Histogram
rateLimited metric.Int64Counter
banned metric.Int64Counter
blocklisted metric.Int64Counter
active *activeUsers
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
localColdStart metric.Int64Counter
@@ -40,7 +43,7 @@ type serverMetrics struct {
// falling back to a no-op histogram on the (rare) construction error. The
// active_users gauge is registered as an observable callback over the in-memory
// tracker.
func newServerMetrics(meter metric.Meter) *serverMetrics {
func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetrics {
if meter == nil {
meter = noop.NewMeterProvider().Meter(meterName)
}
@@ -68,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
}
m := &serverMetrics{
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
blocklisted: counterOf(meter, "gateway_blocklist_blocked_total",
"Requests refused at the edge by the community IP blocklist (Spamhaus DROP)."),
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
@@ -90,6 +95,25 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
return nil
}, gauge)
}
// Community blocklist status: the feed size and how stale it is, for the dashboard and the
// "feed not refreshing" alert. Age is observed only once a feed has loaded, so a disabled or
// never-fetched blocklist reports no age (and entries 0).
if bl != nil {
entries, e1 := meter.Int64ObservableGauge("gateway_blocklist_entries",
metric.WithDescription("CIDR ranges in the active community IP blocklist feed."))
age, e2 := meter.Float64ObservableGauge("gateway_blocklist_age_seconds",
metric.WithDescription("Seconds since the community IP blocklist feed was last successfully fetched (absent until the first fetch)."))
if e1 == nil && e2 == nil {
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
o.ObserveInt64(entries, int64(bl.Len()))
if last := bl.LastFetch(); !last.IsZero() {
o.ObserveFloat64(age, time.Since(last).Seconds())
}
return nil
}, entries, age)
}
}
return m
}
@@ -118,6 +142,11 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
// recordBlocklistBlock counts one request refused by the community IP blocklist.
func (m *serverMetrics) recordBlocklistBlock(ctx context.Context) {
m.blocklisted.Add(ctx, 1)
}
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
+4 -4
View File
@@ -16,7 +16,7 @@ func TestEdgeMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
@@ -74,7 +74,7 @@ func TestRateLimitedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordRateLimited(ctx, "user")
m.recordRateLimited(ctx, "user")
@@ -112,7 +112,7 @@ func TestBannedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordBan(ctx, "tripwire")
m.recordBan(ctx, "tripwire")
@@ -150,7 +150,7 @@ func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
+141 -2
View File
@@ -26,6 +26,7 @@ import (
"golang.org/x/net/http2/h2c"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/clientver"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
@@ -46,6 +47,21 @@ const heartbeatKind = "heartbeat"
// spoofer, so trusting it is safe.
const honeypotHeader = "X-Scrabble-Honeypot"
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
// the edge can turn away a build too old to speak the current wire contract, before it decodes
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "you must update". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
// on a served Execute response when the client is at or above the hard minimum but below the
// recommended version, it nudges an update without failing the call. Headers are the
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
// contract (docs/ARCHITECTURE.md §2).
const (
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
updateRecommendedHeader = "X-Update-Recommended"
)
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report.
const (
@@ -77,6 +93,7 @@ type Server struct {
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
blocklist *ratelimit.Blocklist
honeytoken string
vkAppSecret string
hub *push.Hub
@@ -87,6 +104,17 @@ type Server struct {
maxBodyBytes int
// minClient is the lowest client version served; gateOn is false when the gate is dormant
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
minClient clientver.Version
gateOn bool
// recClient is the version below which a served client is nudged to update (the soft tier);
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
// unparseable). It is independent of the hard gate.
recClient clientver.Version
recOn bool
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
@@ -109,6 +137,9 @@ type Deps struct {
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
// (inert) banlist.
Banlist *ratelimit.Banlist
// Blocklist enforces the community IP blocklist on the hot path; nil selects a
// disabled (inert) blocklist.
Blocklist *ratelimit.Blocklist
// Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm.
Honeytoken string
@@ -124,6 +155,13 @@ type Deps struct {
// MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant.
MinClientVersion string
// RecommendedClientVersion is the version below which a served client is nudged to update (the
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
RecommendedClientVersion string
}
// NewServer constructs the edge service.
@@ -148,10 +186,38 @@ func NewServer(d Deps) *Server {
if banlist == nil {
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
}
blocklist := d.Blocklist
if blocklist == nil {
blocklist = ratelimit.NewBlocklist(false, nil)
}
rl := d.RateLimit
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
}
// Parse the minimum client version once. Config.validate already rejects an unparseable
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
// the gate dormant.
var minClient clientver.Version
gateOn := false
if d.MinClientVersion != "" {
if v, ok := clientver.Parse(d.MinClientVersion); ok {
minClient, gateOn = v, true
} else {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
}
}
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
// construction; an empty value leaves the soft tier dormant.
var recClient clientver.Version
recOn := false
if d.RecommendedClientVersion != "" {
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
recClient, recOn = v, true
} else {
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
}
}
return &Server{
registry: d.Registry,
sessions: d.Sessions,
@@ -160,13 +226,18 @@ func NewServer(d Deps) *Server {
limiter: limiter,
tracker: tracker,
banlist: banlist,
blocklist: blocklist,
honeytoken: d.Honeytoken,
hub: d.Hub,
heartbeat: d.Heartbeat,
log: log,
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter),
metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody,
minClient: minClient,
gateOn: gateOn,
recClient: recClient,
recOn: recOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -255,6 +326,13 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
http.Error(w, "banned", http.StatusTooManyRequests)
return
}
// The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work.
// Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist.
if s.blocklist.Blocked(ip) {
s.metrics.recordBlocklistBlock(r.Context())
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get(honeypotHeader) != "" {
s.log.Warn("honeypot tripwire",
zap.String("path", r.URL.Path),
@@ -269,15 +347,71 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
})
}
// clientTooOld reports whether the X-Client-Version header names a version below the configured
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
// false. The header is a client-controlled compatibility signal, not an access control (it is
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
// non-browser caller — must never be blocked spuriously.
func (s *Server) clientTooOld(header string) bool {
if !s.gateOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return clientver.Less(v, s.minClient)
}
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
// band — at or above the hard minimum but below the recommended version — so a served response can
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
// leaves minClient at the zero version, which no real version is below, so the band is simply
// "below recommended").
func (s *Server) clientUpdateRecommended(header string) bool {
if !s.recOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
start := time.Now()
msgType := req.Msg.GetMessageType()
result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
// client at or above the hard minimum but below the recommended version is told an update is
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
// version yields no nudge.
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
defer func() {
if recommend && resp != nil {
resp.Header().Set(updateRecommendedHeader, "1")
}
}()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: resultUpdateRequired,
}), nil
}
op, ok := s.registry.Lookup(msgType)
if !ok {
result = "unknown_type"
@@ -347,6 +481,11 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
// A stream carries no result_code, so a too-old client is refused with the Connect
// FailedPrecondition counterpart of the update_required sentinel.
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
}
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil {
return err
+166 -7
View File
@@ -22,8 +22,13 @@ import (
)
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
// client plus a cleanup func.
// client plus a cleanup func. The client-version gate is off.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
return newEdgeMin(t, "", backendHandler)
}
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -31,12 +36,40 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
RecommendedClientVersion: recommendedVersion,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
@@ -108,6 +141,132 @@ func TestExecuteGuestGate(t *testing.T) {
}
}
// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
// than the configured minimum is turned away with the update_required result code before the
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
func TestExecuteVersionGate(t *testing.T) {
// The backend answers auth.guest with a session; a gated call never reaches it.
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
header string
msgType string
want string
}{
{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.want {
t.Fatalf("result_code = %q, want %q", got, tc.want)
}
})
}
}
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
// and a dormant soft tier get no header.
func TestExecuteUpdateRecommended(t *testing.T) {
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
rec string
header string
wantResult string
wantHeader bool
}{
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
}
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
}
})
}
}
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate).
func TestSubscribeVersionGate(t *testing.T) {
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
tests := []struct {
name string
min string
header string
want connect.Code
}{
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, noBackend)
defer cleanup()
req := connect.NewRequest(&edgev1.SubscribeRequest{})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
stream, err := client.Subscribe(context.Background(), req)
if err == nil {
for stream.Receive() {
}
err = stream.Err()
}
if got := connect.CodeOf(err); got != tc.want {
t.Fatalf("code = %v, want %v", got, tc.want)
}
})
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session")
+188
View File
@@ -0,0 +1,188 @@
package ratelimit
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"net"
"sort"
"strings"
"sync"
"time"
)
// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against.
type ipRange struct{ lo, hi uint32 }
// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced
// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an
// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is
// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it).
// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false.
type Blocklist struct {
enabled bool
allow []ipRange // sorted, non-overlapping; from config, immutable after construction
mu sync.RWMutex
ranges []ipRange // sorted, non-overlapping; the current feed
fetchedAt time.Time // last successful SetCIDRs; zero = never loaded
}
// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set
// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it.
func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist {
return &Blocklist{enabled: enabled, allow: toRanges(allow)}
}
// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is
// false on a disabled or empty blocklist, and false for any non-IPv4 address.
func (b *Blocklist) Blocked(ip string) bool {
if !b.enabled {
return false
}
v, ok := ipv4ToUint32(ip)
if !ok {
return false
}
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.ranges) == 0 || rangesContain(b.allow, v) {
return false
}
return rangesContain(b.ranges, v)
}
// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored.
func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) {
r := toRanges(cidrs)
b.mu.Lock()
b.ranges = r
b.fetchedAt = at
b.mu.Unlock()
}
// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps
// climbing. Blocked then returns false until a fresh feed loads.
func (b *Blocklist) Clear() {
b.mu.Lock()
b.ranges = nil
b.mu.Unlock()
}
// Len returns the number of ranges currently enforced.
func (b *Blocklist) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.ranges)
}
// LastFetch returns the time of the last successful feed load (zero if never).
func (b *Blocklist) LastFetch() time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
return b.fetchedAt
}
// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics.
type RefreshOutcome int
const (
// RefreshUpdated: a new feed was fetched and applied.
RefreshUpdated RefreshOutcome = iota
// RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept.
RefreshKept
// RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open).
RefreshDropped
)
// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it
// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open
// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock.
func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome {
if fetchErr == nil {
bl.SetCIDRs(cidrs, now)
return RefreshUpdated
}
last := bl.LastFetch()
if last.IsZero() || now.Sub(last) > maxStaleness {
bl.Clear()
return RefreshDropped
}
return RefreshKept
}
// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail,
// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and
// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed.
func ParseDROP(r io.Reader) ([]*net.IPNet, error) {
var out []*net.IPNet
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
line := sc.Text()
if i := strings.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !strings.Contains(line, "/") {
line += "/32"
}
_, ipnet, err := net.ParseCIDR(line)
if err != nil || ipnet.IP.To4() == nil {
continue
}
out = append(out, ipnet)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("ratelimit: read blocklist: %w", err)
}
return out, nil
}
// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs
// are dropped. Sorting by lo lets [rangesContain] binary-search.
func toRanges(cidrs []*net.IPNet) []ipRange {
out := make([]ipRange, 0, len(cidrs))
for _, n := range cidrs {
if n == nil {
continue
}
ip4 := n.IP.To4()
if ip4 == nil {
continue
}
ones, bits := n.Mask.Size()
if bits != 32 {
continue
}
lo := binary.BigEndian.Uint32(ip4)
hi := lo | (uint32(0xffffffff) >> uint(ones))
out = append(out, ipRange{lo: lo, hi: hi})
}
sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo })
return out
}
// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a
// binary search for the last range whose lo is at most v.
func rangesContain(ranges []ipRange, v uint32) bool {
i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v })
return i > 0 && ranges[i-1].hi >= v
}
// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4.
func ipv4ToUint32(s string) (uint32, bool) {
ip := net.ParseIP(s)
if ip == nil {
return 0, false
}
ip4 := ip.To4()
if ip4 == nil {
return 0, false
}
return binary.BigEndian.Uint32(ip4), true
}
@@ -0,0 +1,106 @@
package ratelimit
import (
"errors"
"net"
"strings"
"testing"
"time"
)
// cidrs parses CIDR strings into networks for a test fixture.
func cidrs(t *testing.T, ss ...string) []*net.IPNet {
t.Helper()
out := make([]*net.IPNet, 0, len(ss))
for _, s := range ss {
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("bad cidr %q: %v", s, err)
}
out = append(out, n)
}
return out
}
func TestBlocklistBlocked(t *testing.T) {
bl := NewBlocklist(true, cidrs(t, "10.20.30.0/24")) // allowlist
bl.SetCIDRs(cidrs(t, "1.2.3.0/24", "203.0.113.5/32", "198.51.100.0/28", "10.20.30.0/24"), time.Now())
cases := map[string]bool{
"1.2.3.0": true, // range start
"1.2.3.255": true, // range end
"1.2.4.0": false, // just past the /24
"203.0.113.5": true, // /32 exact
"203.0.113.6": false,
"198.51.100.15": true, // within /28
"198.51.100.16": false, // past the /28
"9.9.9.9": false, // not listed
"10.20.30.99": false, // listed BUT allowlisted — never blocked
"::1": false, // IPv6 — never matched here
"not-an-ip": false,
}
for ip, want := range cases {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%q) = %v, want %v", ip, got, want)
}
}
}
func TestBlocklistDisabledOrEmpty(t *testing.T) {
off := NewBlocklist(false, nil)
off.SetCIDRs(cidrs(t, "1.2.3.0/24"), time.Now())
if off.Blocked("1.2.3.4") {
t.Error("a disabled blocklist must not block")
}
empty := NewBlocklist(true, nil)
if empty.Blocked("1.2.3.4") {
t.Error("an empty (never-loaded) blocklist must not block")
}
}
func TestParseDROP(t *testing.T) {
in := "; Spamhaus DROP\n" +
"1.2.3.0/24 ; SBL1\n" +
" 198.51.100.0/28 ; SBL2\n" +
"\n" +
"203.0.113.5 ; a bare IP -> /32\n" +
"2001:db8::/32 ; IPv6 skipped\n" +
"garbage line\n"
nets, err := ParseDROP(strings.NewReader(in))
if err != nil {
t.Fatal(err)
}
if len(nets) != 3 {
t.Fatalf("got %d networks, want 3: %v", len(nets), nets)
}
bl := NewBlocklist(true, nil)
bl.SetCIDRs(nets, time.Now())
for ip, want := range map[string]bool{"1.2.3.9": true, "198.51.100.1": true, "203.0.113.5": true, "203.0.113.6": false, "2001:db8::1": false} {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%s) = %v, want %v", ip, got, want)
}
}
}
func TestApplyRefresh(t *testing.T) {
bl := NewBlocklist(true, nil)
now := time.Now()
if o := ApplyRefresh(bl, cidrs(t, "1.2.3.0/24"), nil, now, time.Hour); o != RefreshUpdated {
t.Fatalf("success: want RefreshUpdated, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Fatal("a successful refresh must apply the feed")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(30*time.Minute), time.Hour); o != RefreshKept {
t.Fatalf("fresh failure: want RefreshKept, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Error("a kept feed must still block")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(2*time.Hour), time.Hour); o != RefreshDropped {
t.Fatalf("stale failure: want RefreshDropped, got %v", o)
}
if bl.Blocked("1.2.3.4") {
t.Error("a stale feed must be dropped (fail-open)")
}
}
+182 -79
View File
@@ -30,13 +30,14 @@ const (
)
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command.
// Ack per Command and a periodic Health report.
type FromBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Msg:
//
// *FromBot_Hello
// *FromBot_Ack
// *FromBot_Health
Msg isFromBot_Msg `protobuf_oneof:"msg"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -97,6 +98,15 @@ func (x *FromBot) GetAck() *Ack {
return nil
}
func (x *FromBot) GetHealth() *Health {
if x != nil {
if x, ok := x.Msg.(*FromBot_Health); ok {
return x.Health
}
}
return nil
}
type isFromBot_Msg interface {
isFromBot_Msg()
}
@@ -109,10 +119,92 @@ type FromBot_Ack struct {
Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
}
type FromBot_Health struct {
Health *Health `protobuf:"bytes,3,opt,name=health,proto3,oneof"`
}
func (*FromBot_Hello) isFromBot_Msg() {}
func (*FromBot_Ack) isFromBot_Msg() {}
func (*FromBot_Health) isFromBot_Msg() {}
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
type Health struct {
state protoimpl.MessageState `protogen:"open.v1"`
ConnectFailures uint64 `protobuf:"varint,1,opt,name=connect_failures,json=connectFailures,proto3" json:"connect_failures,omitempty"` // getUpdates long-poll failures (transport / 5xx) since the last report
ApiErrors uint64 `protobuf:"varint,2,opt,name=api_errors,json=apiErrors,proto3" json:"api_errors,omitempty"` // other Bot API failures (transport / 5xx) since the last report
RateLimited uint64 `protobuf:"varint,3,opt,name=rate_limited,json=rateLimited,proto3" json:"rate_limited,omitempty"` // Bot API 429 responses since the last report (should stay ~0)
LastOkUnix int64 `protobuf:"varint,4,opt,name=last_ok_unix,json=lastOkUnix,proto3" json:"last_ok_unix,omitempty"` // unix seconds of the last successful Bot API response (0 = none yet)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Health) Reset() {
*x = Health{}
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Health) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Health) ProtoMessage() {}
func (x *Health) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Health.ProtoReflect.Descriptor instead.
func (*Health) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
}
func (x *Health) GetConnectFailures() uint64 {
if x != nil {
return x.ConnectFailures
}
return 0
}
func (x *Health) GetApiErrors() uint64 {
if x != nil {
return x.ApiErrors
}
return 0
}
func (x *Health) GetRateLimited() uint64 {
if x != nil {
return x.RateLimited
}
return 0
}
func (x *Health) GetLastOkUnix() int64 {
if x != nil {
return x.LastOkUnix
}
return 0
}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
type ToBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -123,7 +215,7 @@ type ToBot struct {
func (x *ToBot) Reset() {
*x = ToBot{}
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -135,7 +227,7 @@ func (x *ToBot) String() string {
func (*ToBot) ProtoMessage() {}
func (x *ToBot) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -148,7 +240,7 @@ func (x *ToBot) ProtoReflect() protoreflect.Message {
// Deprecated: Use ToBot.ProtoReflect.Descriptor instead.
func (*ToBot) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
}
func (x *ToBot) GetCommand() *Command {
@@ -171,7 +263,7 @@ type Hello struct {
func (x *Hello) Reset() {
*x = Hello{}
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -183,7 +275,7 @@ func (x *Hello) String() string {
func (*Hello) ProtoMessage() {}
func (x *Hello) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -196,7 +288,7 @@ func (x *Hello) ProtoReflect() protoreflect.Message {
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
func (*Hello) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
}
func (x *Hello) GetInstanceId() string {
@@ -233,7 +325,7 @@ type Command struct {
func (x *Command) Reset() {
*x = Command{}
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -245,7 +337,7 @@ func (x *Command) String() string {
func (*Command) ProtoMessage() {}
func (x *Command) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -258,7 +350,7 @@ func (x *Command) ProtoReflect() protoreflect.Message {
// Deprecated: Use Command.ProtoReflect.Descriptor instead.
func (*Command) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
}
func (x *Command) GetCommandId() string {
@@ -372,7 +464,7 @@ type Ack struct {
func (x *Ack) Reset() {
*x = Ack{}
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -384,7 +476,7 @@ func (x *Ack) String() string {
func (*Ack) ProtoMessage() {}
func (x *Ack) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -397,7 +489,7 @@ func (x *Ack) ProtoReflect() protoreflect.Message {
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
func (*Ack) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
}
func (x *Ack) GetCommandId() string {
@@ -445,7 +537,7 @@ type ChatGateCommand struct {
func (x *ChatGateCommand) Reset() {
*x = ChatGateCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -457,7 +549,7 @@ func (x *ChatGateCommand) String() string {
func (*ChatGateCommand) ProtoMessage() {}
func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -470,7 +562,7 @@ func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead.
func (*ChatGateCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
}
func (x *ChatGateCommand) GetExternalId() string {
@@ -498,7 +590,7 @@ type ChatEligibilityRequest struct {
func (x *ChatEligibilityRequest) Reset() {
*x = ChatEligibilityRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -510,7 +602,7 @@ func (x *ChatEligibilityRequest) String() string {
func (*ChatEligibilityRequest) ProtoMessage() {}
func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -523,7 +615,7 @@ func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead.
func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
}
func (x *ChatEligibilityRequest) GetExternalId() string {
@@ -546,7 +638,7 @@ type ChatEligibilityResponse struct {
func (x *ChatEligibilityResponse) Reset() {
*x = ChatEligibilityResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -558,7 +650,7 @@ func (x *ChatEligibilityResponse) String() string {
func (*ChatEligibilityResponse) ProtoMessage() {}
func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -571,7 +663,7 @@ func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead.
func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
}
func (x *ChatEligibilityResponse) GetRegistered() bool {
@@ -605,7 +697,7 @@ type CreateInvoiceCommand struct {
func (x *CreateInvoiceCommand) Reset() {
*x = CreateInvoiceCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -617,7 +709,7 @@ func (x *CreateInvoiceCommand) String() string {
func (*CreateInvoiceCommand) ProtoMessage() {}
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -630,7 +722,7 @@ func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
}
func (x *CreateInvoiceCommand) GetTitle() string {
@@ -674,7 +766,7 @@ type PreCheckoutRequest struct {
func (x *PreCheckoutRequest) Reset() {
*x = PreCheckoutRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -686,7 +778,7 @@ func (x *PreCheckoutRequest) String() string {
func (*PreCheckoutRequest) ProtoMessage() {}
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -699,7 +791,7 @@ func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
}
func (x *PreCheckoutRequest) GetOrderId() string {
@@ -735,7 +827,7 @@ type PreCheckoutResponse struct {
func (x *PreCheckoutResponse) Reset() {
*x = PreCheckoutResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -747,7 +839,7 @@ func (x *PreCheckoutResponse) String() string {
func (*PreCheckoutResponse) ProtoMessage() {}
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -760,7 +852,7 @@ func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
}
func (x *PreCheckoutResponse) GetOk() bool {
@@ -792,7 +884,7 @@ type ForwardPaymentRequest struct {
func (x *ForwardPaymentRequest) Reset() {
*x = ForwardPaymentRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -804,7 +896,7 @@ func (x *ForwardPaymentRequest) String() string {
func (*ForwardPaymentRequest) ProtoMessage() {}
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -817,7 +909,7 @@ func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
}
func (x *ForwardPaymentRequest) GetOrderId() string {
@@ -861,7 +953,7 @@ type ForwardPaymentResponse struct {
func (x *ForwardPaymentResponse) Reset() {
*x = ForwardPaymentResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -873,7 +965,7 @@ func (x *ForwardPaymentResponse) String() string {
func (*ForwardPaymentResponse) ProtoMessage() {}
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -886,7 +978,7 @@ func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{13}
}
func (x *ForwardPaymentResponse) GetCredited() bool {
@@ -900,11 +992,19 @@ var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" +
"\n" +
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"r\n" +
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"\xa9\x01\n" +
"\aFromBot\x122\n" +
"\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" +
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ackB\x05\n" +
"\x03msg\"?\n" +
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ack\x125\n" +
"\x06health\x18\x03 \x01(\v2\x1b.scrabble.botlink.v1.HealthH\x00R\x06healthB\x05\n" +
"\x03msg\"\x97\x01\n" +
"\x06Health\x12)\n" +
"\x10connect_failures\x18\x01 \x01(\x04R\x0fconnectFailures\x12\x1d\n" +
"\n" +
"api_errors\x18\x02 \x01(\x04R\tapiErrors\x12!\n" +
"\frate_limited\x18\x03 \x01(\x04R\vrateLimited\x12 \n" +
"\flast_ok_unix\x18\x04 \x01(\x03R\n" +
"lastOkUnix\"?\n" +
"\x05ToBot\x126\n" +
"\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" +
"\x05Hello\x12\x1f\n" +
@@ -976,47 +1076,49 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
return file_botlink_v1_botlink_proto_rawDescData
}
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 2: scrabble.botlink.v1.Hello
(*Command)(nil), // 3: scrabble.botlink.v1.Command
(*Ack)(nil), // 4: scrabble.botlink.v1.Ack
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
(*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
(*Health)(nil), // 1: scrabble.botlink.v1.Health
(*ToBot)(nil), // 2: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 3: scrabble.botlink.v1.Hello
(*Command)(nil), // 4: scrabble.botlink.v1.Command
(*Ack)(nil), // 5: scrabble.botlink.v1.Ack
(*ChatGateCommand)(nil), // 6: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 7: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 8: scrabble.botlink.v1.ChatEligibilityResponse
(*CreateInvoiceCommand)(nil), // 9: scrabble.botlink.v1.CreateInvoiceCommand
(*PreCheckoutRequest)(nil), // 10: scrabble.botlink.v1.PreCheckoutRequest
(*PreCheckoutResponse)(nil), // 11: scrabble.botlink.v1.PreCheckoutResponse
(*ForwardPaymentRequest)(nil), // 12: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 13: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 14: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 15: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 16: scrabble.telegram.v1.SendToGameChannelRequest
}
var file_botlink_v1_botlink_proto_depIdxs = []int32{
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
12, // [12:16] is the sub-list for method output_type
8, // [8:12] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
3, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
5, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
1, // 2: scrabble.botlink.v1.FromBot.health:type_name -> scrabble.botlink.v1.Health
4, // 3: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
14, // 4: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
15, // 5: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
16, // 6: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
6, // 7: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
9, // 8: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
0, // 9: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
10, // 11: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
12, // 12: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
2, // 13: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
8, // 14: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
11, // 15: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
13, // 16: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
13, // [13:17] is the sub-list for method output_type
9, // [9:13] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
}
func init() { file_botlink_v1_botlink_proto_init() }
@@ -1027,8 +1129,9 @@ func file_botlink_v1_botlink_proto_init() {
file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{
(*FromBot_Hello)(nil),
(*FromBot_Ack)(nil),
(*FromBot_Health)(nil),
}
file_botlink_v1_botlink_proto_msgTypes[3].OneofWrappers = []any{
file_botlink_v1_botlink_proto_msgTypes[4].OneofWrappers = []any{
(*Command_Notify)(nil),
(*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil),
@@ -1041,7 +1144,7 @@ func file_botlink_v1_botlink_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
NumEnums: 0,
NumMessages: 13,
NumMessages: 14,
NumExtensions: 0,
NumServices: 1,
},
+17 -1
View File
@@ -48,14 +48,30 @@ service BotLink {
}
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command.
// Ack per Command and a periodic Health report.
message FromBot {
oneof msg {
Hello hello = 1;
Ack ack = 2;
Health health = 3;
}
}
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
// bot's Bot-API health as its own metrics the bot exports no telemetry of its own (otelcol lives
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
// long-poll that returns every poll cycle even when idle) an absolute liveness stamp the gateway
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
message Health {
uint64 connect_failures = 1; // getUpdates long-poll failures (transport / 5xx) since the last report
uint64 api_errors = 2; // other Bot API failures (transport / 5xx) since the last report
uint64 rate_limited = 3; // Bot API 429 responses since the last report (should stay ~0)
int64 last_ok_unix = 4; // unix seconds of the last successful Bot API response (0 = none yet)
}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
message ToBot {
Command command = 1;
+14
View File
@@ -23,6 +23,7 @@ import (
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/health"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
@@ -31,6 +32,10 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
// healthReportInterval is how often the bot flushes its accumulated Bot API health to the gateway
// over the bot-link. It bounds how stale a metric can be, not how often the bot talks to Telegram.
const healthReportInterval = 30 * time.Second
func main() {
cfg, err := config.LoadBot()
if err != nil {
@@ -81,6 +86,12 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
}
}
// The bot exports no telemetry of its own (otelcol is on the main host, unreachable from here),
// so its Bot API health is observed centrally and reported to the gateway over the bot-link,
// which turns the reports into metrics. Shared between the bot (which feeds it) and the bot-link
// client (which flushes it).
healthReporter := health.NewReporter()
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
@@ -92,6 +103,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
AcceptPayments: cfg.StarsOutboxDir != "",
Health: healthReporter,
}, logger)
if err != nil {
return err
@@ -120,6 +132,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
OwnsUpdates: cfg.OwnsUpdates,
Creds: credentials.NewTLS(tlsCfg),
ReconnectDelay: cfg.BotLink.ReconnectDelay,
Health: healthReporter,
HealthInterval: healthReportInterval,
}, exec, logger)
if err != nil {
return err
+16
View File
@@ -7,19 +7,26 @@ package bot
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"golang.org/x/time/rate"
"scrabble/platform/telegram/internal/health"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/support"
)
// botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set
// it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout.
const botPollTimeout = time.Minute
// Config configures the bot wrapper.
type Config struct {
// Token is the Bot API token.
@@ -54,6 +61,9 @@ type Config struct {
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
AcceptPayments bool
// Health, when set, observes every Bot API request for health metrics (reported to the gateway
// over the bot-link) and honours a 429's Retry-After. Nil disables the observation.
Health *health.Reporter
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -159,6 +169,12 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if cfg.APIBaseURL != "" {
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
}
if cfg.Health != nil {
// Observe every Bot API request centrally for health metrics and the 429 back-off. The
// client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side).
base := &http.Client{Timeout: botPollTimeout + 10*time.Second}
opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base)))
}
api, err := tgbot.New(cfg.Token, opts...)
if err != nil {
return nil, err
+57 -14
View File
@@ -11,6 +11,7 @@ import (
"google.golang.org/grpc/keepalive"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
"scrabble/platform/telegram/internal/health"
)
const (
@@ -34,6 +35,11 @@ type ClientConfig struct {
Creds credentials.TransportCredentials
// ReconnectDelay is the pause before re-dialing after the stream ends.
ReconnectDelay time.Duration
// Health, when set with a positive HealthInterval, is flushed to the gateway as a botlink Health
// message every HealthInterval while the stream is open. Nil disables health reporting.
Health *health.Reporter
// HealthInterval is the cadence of the Health flush; 0 disables it.
HealthInterval time.Duration
}
// Client maintains the long-lived bot-link to the gateway, executing the commands
@@ -140,22 +146,59 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
}
c.log.Info("bot-link connected", zap.String("gateway", c.cfg.GatewayAddr), zap.Bool("owns_updates", c.cfg.OwnsUpdates))
// One goroutine owns stream.Recv, feeding commands to the loop below; the loop owns every
// stream.Send (the Acks and the periodic Health) — a gRPC stream forbids concurrent Send.
rctx, cancel := context.WithCancel(ctx)
defer cancel()
cmds := make(chan *botlinkv1.Command)
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
if cmd := msg.GetCommand(); cmd != nil {
select {
case cmds <- cmd:
case <-rctx.Done():
return
}
}
}
}()
var healthTick <-chan time.Time
if c.cfg.Health != nil && c.cfg.HealthInterval > 0 {
t := time.NewTicker(c.cfg.HealthInterval)
defer t.Stop()
healthTick = t.C
}
for {
msg, err := stream.Recv()
if err != nil {
return err
}
cmd := msg.GetCommand()
if cmd == nil {
continue
}
delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
if herr != nil {
ack.Error = herr.Error()
}
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-recvErr:
return err
case cmd := <-cmds:
delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
if herr != nil {
ack.Error = herr.Error()
}
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
return err
}
case <-healthTick:
// Snapshot without resetting; only commit (clear the deltas) after a successful send, so a
// failed flush loses nothing and the counts ride the next connection.
hb := c.cfg.Health.Snapshot()
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Health{Health: hb}}); err != nil {
return err
}
c.cfg.Health.Commit(hb)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
// Package health tracks the Telegram bot's Bot API health and reports it to the gateway over the
// bot-link. The bot exports no telemetry of its own — otelcol lives on the main host, unreachable
// from the bot host — so the gateway turns these reports into its own metrics (see
// gateway/internal/botlink). Health is observed CENTRALLY by wrapping the Bot API HTTP client
// ([Reporter.Wrap]); every request is classified there with no per-call-site instrumentation.
package health
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
)
// maxBackoff caps how long a single 429 makes the bot wait, so a hostile or buggy Retry-After can
// never wedge the bot.
const maxBackoff = 30 * time.Second
// Reporter accumulates the bot's Bot API health between reports: three delta counters and the last
// successful-response stamp. It is safe for concurrent use. The bot-link client periodically
// [Reporter.Snapshot]s it, sends the snapshot as a botlink Health, and [Reporter.Commit]s it on a
// successful send.
type Reporter struct {
connectFailures atomic.Int64
apiErrors atomic.Int64
rateLimited atomic.Int64
lastOKUnix atomic.Int64
}
// NewReporter returns an empty Reporter.
func NewReporter() *Reporter { return &Reporter{} }
// Snapshot reads the current delta counters and the last-ok stamp into a Health message WITHOUT
// resetting them. The counters are cleared only by [Reporter.Commit] after a successful send, so a
// failed send loses nothing.
func (r *Reporter) Snapshot() *botlinkv1.Health {
return &botlinkv1.Health{
ConnectFailures: uint64(max(r.connectFailures.Load(), 0)),
ApiErrors: uint64(max(r.apiErrors.Load(), 0)),
RateLimited: uint64(max(r.rateLimited.Load(), 0)),
LastOkUnix: r.lastOKUnix.Load(),
}
}
// Commit subtracts a just-sent snapshot's delta counters, so counts accrued between the snapshot and
// the send survive into the next report. last_ok is a stamp, not a delta, so it is left as is.
func (r *Reporter) Commit(sent *botlinkv1.Health) {
r.connectFailures.Add(-int64(sent.GetConnectFailures()))
r.apiErrors.Add(-int64(sent.GetApiErrors()))
r.rateLimited.Add(-int64(sent.GetRateLimited()))
}
// Wrap returns an http.Client-shaped observer over base for tgbot.WithHTTPClient: it stamps liveness
// on every successful response, counts transport and 5xx failures (split getUpdates vs other) and
// 429s, and honours a 429's Retry-After (bounded by maxBackoff) so the bot backs off instead of
// hammering. A 4xx other than 429 (a user blocked the bot, a malformed request) is a normal
// per-request outcome, not a health signal, and is not counted.
func (r *Reporter) Wrap(base Doer) Doer { return &observer{base: base, r: r} }
// Doer is the minimal HTTP surface the Bot API client needs; both *http.Client and [observer]
// satisfy it, and it matches the go-telegram/bot HttpClient interface.
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
// observer is the Bot API HTTP client wrapper (see [Reporter.Wrap]).
type observer struct {
base Doer
r *Reporter
}
// Do performs the request and records its health outcome.
func (o *observer) Do(req *http.Request) (*http.Response, error) {
resp, err := o.base.Do(req)
poll := strings.HasSuffix(req.URL.Path, "/getUpdates")
if err != nil {
// A cancelled context is a shutdown, not a Bot API failure.
if req.Context().Err() == nil {
o.fail(poll)
}
return resp, err
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
o.r.rateLimited.Add(1)
o.backoff(req.Context(), resp)
case resp.StatusCode >= 200 && resp.StatusCode < 300:
o.r.lastOKUnix.Store(time.Now().Unix())
case resp.StatusCode >= 500:
o.fail(poll)
}
return resp, err
}
// fail records a connect failure on the getUpdates long-poll or an api error otherwise.
func (o *observer) fail(poll bool) {
if poll {
o.r.connectFailures.Add(1)
} else {
o.r.apiErrors.Add(1)
}
}
// backoff waits out a 429's Retry-After (bounded, cancellable). It buffers the response body first so
// the wait holds no connection open and the Bot API library can still parse the 429 afterwards.
func (o *observer) backoff(ctx context.Context, resp *http.Response) {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
d := retryAfter(resp.Header.Get("Retry-After"), body)
if d <= 0 {
return
}
if d > maxBackoff {
d = maxBackoff
}
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// retryAfter resolves the 429 back-off from the Retry-After header, falling back to the Bot API
// body's parameters.retry_after (both are seconds). It returns 0 when neither gives a positive value.
func retryAfter(header string, body []byte) time.Duration {
if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
var payload struct {
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
if err := json.Unmarshal(body, &payload); err == nil && payload.Parameters.RetryAfter > 0 {
return time.Duration(payload.Parameters.RetryAfter) * time.Second
}
return 0
}
@@ -0,0 +1,121 @@
package health
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeDoer returns a canned response and error for every request.
type fakeDoer struct {
resp *http.Response
err error
}
func (f *fakeDoer) Do(*http.Request) (*http.Response, error) { return f.resp, f.err }
// mkResp builds a response with a status, optional Retry-After header and body.
func mkResp(status int, retryAfter, body string) *http.Response {
h := http.Header{}
if retryAfter != "" {
h.Set("Retry-After", retryAfter)
}
return &http.Response{StatusCode: status, Header: h, Body: io.NopCloser(strings.NewReader(body))}
}
// run sends one request for the Bot API method through a fresh observer and returns the reporter's
// resulting snapshot. The 429 cases carry no Retry-After, so the back-off returns before any wait.
func run(method string, resp *http.Response, err error) *Reporter {
r := NewReporter()
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/"+method, nil)
_, _ = r.Wrap(&fakeDoer{resp: resp, err: err}).Do(req)
return r
}
func TestObserverClassifies(t *testing.T) {
cases := []struct {
name string
method string
resp *http.Response
err error
wantConnect uint64
wantAPI uint64
wantRate uint64
wantLastOKSet bool
}{
{"poll ok stamps liveness", "getUpdates", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
{"send ok stamps liveness", "sendMessage", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
{"poll 5xx is a connect failure", "getUpdates", mkResp(502, "", ""), nil, 1, 0, 0, false},
{"send 5xx is an api error", "sendMessage", mkResp(500, "", ""), nil, 0, 1, 0, false},
{"429 is rate limited, not an error", "sendMessage", mkResp(429, "", `{"ok":false}`), nil, 0, 0, 1, false},
{"4xx other than 429 is not counted", "sendMessage", mkResp(403, "", ""), nil, 0, 0, 0, false},
{"poll transport error is a connect failure", "getUpdates", nil, errors.New("dial"), 1, 0, 0, false},
{"send transport error is an api error", "sendMessage", nil, errors.New("dial"), 0, 1, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := run(tc.method, tc.resp, tc.err).Snapshot()
if s.GetConnectFailures() != tc.wantConnect || s.GetApiErrors() != tc.wantAPI || s.GetRateLimited() != tc.wantRate {
t.Errorf("counters = connect %d api %d rate %d, want %d/%d/%d",
s.GetConnectFailures(), s.GetApiErrors(), s.GetRateLimited(), tc.wantConnect, tc.wantAPI, tc.wantRate)
}
if (s.GetLastOkUnix() > 0) != tc.wantLastOKSet {
t.Errorf("last_ok set = %v, want %v", s.GetLastOkUnix() > 0, tc.wantLastOKSet)
}
})
}
}
// TestObserverIgnoresShutdownTransportError checks a transport error under a cancelled context (a
// shutdown) is not counted as a Bot API failure.
func TestObserverIgnoresShutdownTransportError(t *testing.T) {
r := NewReporter()
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/getUpdates", nil)
ctx, cancel := context.WithCancel(req.Context())
cancel()
_, _ = r.Wrap(&fakeDoer{err: errors.New("dial")}).Do(req.WithContext(ctx))
if s := r.Snapshot(); s.GetConnectFailures() != 0 {
t.Errorf("shutdown transport error must not count, got connect=%d", s.GetConnectFailures())
}
}
func TestRetryAfter(t *testing.T) {
cases := []struct {
header string
body string
want time.Duration
}{
{"5", "", 5 * time.Second},
{"", `{"parameters":{"retry_after":7}}`, 7 * time.Second},
{"3", `{"parameters":{"retry_after":9}}`, 3 * time.Second}, // header wins
{"", `{"ok":false}`, 0},
{"0", "", 0},
{"", "not json", 0},
}
for _, tc := range cases {
if got := retryAfter(tc.header, []byte(tc.body)); got != tc.want {
t.Errorf("retryAfter(%q, %q) = %v, want %v", tc.header, tc.body, got, tc.want)
}
}
}
// TestSnapshotCommit checks Commit clears only the sent deltas, so counts accrued between the
// snapshot and the send survive into the next report.
func TestSnapshotCommit(t *testing.T) {
r := NewReporter()
r.apiErrors.Add(3)
snap := r.Snapshot()
if snap.GetApiErrors() != 3 {
t.Fatalf("snapshot api_errors = %d, want 3", snap.GetApiErrors())
}
r.apiErrors.Add(2) // accrues after the snapshot
r.Commit(snap)
if got := r.Snapshot().GetApiErrors(); got != 2 {
t.Errorf("after commit api_errors = %d, want 2 (the post-snapshot accrual)", got)
}
}
+4 -1
View File
@@ -23,7 +23,10 @@ ENV APP_VERSION=${VERSION}
WORKDIR /app
COPY --from=build /src/renderer/node_modules ./node_modules
COPY --from=build /src/renderer/dist ./dist
COPY renderer/src/server.mjs renderer/src/render.mjs ./src/
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
# price list is fetched from the backend at request time and spliced in.
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
USER node
EXPOSE 8090
CMD ["node", "src/server.mjs"]
+22 -12
View File
@@ -1,10 +1,10 @@
# renderer — the finished-game image-render sidecar
# renderer — the render sidecar (finished-game image + public offer page)
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image
build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on
[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is
pixel-identical to the design the owner signed off in the browser.
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
signed off) and the public **offer page**.
## Interface
@@ -12,20 +12,30 @@ pixel-identical to the design the owner signed off in the browser.
`image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`);
`alphabet` is the per-variant `(index, letter, value)` table tile values are drawn
from; `labels` localizes the non-play moves (pass/exchange/resign/timeout);
`hostname` + `dateLocale` feed the footer.
`hostname` + `dateLocale` feed the footer. Internal-only.
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
The service draws and nothing else: authentication, the participant check and the signed
public download URL all live in the backend (`backend/internal/server/export.go`); the
network path is client → gateway `/dl/*` → backend → this sidecar.
The service renders and nothing else: for the PNG, authentication, the participant check and the
signed public download URL all live in the backend (`backend/internal/server/export.go`), the path
being client → gateway `/dl/*` → backend → this sidecar; for the offer, the catalog projection and
its cache live in the backend (`backend/internal/payments/offer.go`) and no user input reaches the
page.
## Development
```sh
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
# (allowBuilds) or the native binary is silently never fetched
pnpm test # bundles, then node --test against testdata/request.json
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
pnpm test # bundles, then node --test (PNG smoke + offer splice)
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
# reachable backend (else the boot read / the price fetch fail):
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
```
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
+4
View File
@@ -9,5 +9,9 @@ await build({
format: 'esm',
platform: 'node',
outfile: 'dist/gameimage.mjs',
// The shared ui/src/lib modules are copied without their node_modules, so a bare npm import they
// make (offer.ts → 'marked', the offer renderer's markdown parser) is not resolvable from the ui
// tree. NODE_PATH-style fallback to the renderer's own node_modules, where marked is a dependency.
nodePaths: ['node_modules'],
logLevel: 'info',
});
+1
View File
@@ -9,6 +9,7 @@
"test": "pnpm run bundle && node --test test/*.test.mjs"
},
"dependencies": {
"marked": "^18.0.5",
"skia-canvas": "^3.0.6"
},
"devDependencies": {
+10
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
marked:
specifier: ^18.0.5
version: 18.0.6
skia-canvas:
specifier: ^3.0.6
version: 3.0.8
@@ -209,6 +212,11 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
marked@18.0.6:
resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==}
engines: {node: '>= 20'}
hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -347,6 +355,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
marked@18.0.6: {}
ms@2.1.3: {}
parenthesis@3.1.8: {}
+7 -4
View File
@@ -1,6 +1,9 @@
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
// The esbuild bundle entry: re-exports the SHARED ui/src/lib modules — the exact modules the
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
// used to invoke, now server-side so the live catalog price list can be spliced in.
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet';
export { renderOfferHtml } from '../../ui/src/lib/offer';
+17
View File
@@ -0,0 +1,17 @@
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
import { renderOfferHtml } from '../dist/gameimage.mjs';
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
// tables before rendering.
export const pricingMarker = '<#pricing_template#>';
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
export function renderOffer(offerMarkdown, pricingTables) {
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
}
+43 -8
View File
@@ -1,20 +1,43 @@
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
// finished-game export image. It runs the same drawGameImage the browser build ships
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
//
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here)
// GET /offer → 301 /offer/
// GET /healthz → 200
//
// The service is internal-only (docker network `internal`); authentication, participant
// checks and the signed public URL all live in the backend — this process only draws.
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
// markdown; no user input reaches it.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { renderRequest } from './render.mjs';
import { renderOffer } from './offer.mjs';
const PORT = Number(process.env.RENDERER_PORT || 8090);
// A render request is a finished game's journal — generously capped.
const MAX_BODY = 2 * 1024 * 1024;
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked
// path for a local run (point it at ../ui/legal/offer_ru.md).
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8');
const OFFER_PRICING_URL =
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
// fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a
// 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list.
async function fetchOfferPricing() {
const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) {
throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 });
}
return resp.text();
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
@@ -35,11 +58,23 @@ function readBody(req) {
const server = createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/healthz') {
const path = (req.url || '').split('?')[0];
if (req.method === 'GET' && path === '/healthz') {
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
return;
}
if (req.method === 'POST' && req.url === '/render') {
if (req.method === 'GET' && path === '/offer') {
res.writeHead(301, { location: '/offer/' }).end();
return;
}
if (req.method === 'GET' && path === '/offer/') {
const html = renderOffer(OFFER_MD, await fetchOfferPricing());
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(html);
return;
}
if (req.method === 'POST' && path === '/render') {
const png = await renderRequest(JSON.parse(await readBody(req)));
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
return;
+29
View File
@@ -0,0 +1,29 @@
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts);
// here we assert the substitution and that the tables reach the rendered HTML.
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderOffer, pricingMarker } from '../src/offer.mjs';
const tables =
'| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n' +
'| --- | --- | --- | --- |\n' +
'| 50 «Фишек» | 200.00 | 30 | 100 |';
test('splices the price list into the offer and renders the tables to HTML', () => {
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
const html = renderOffer(md, tables);
// The marker is gone and the projected table reached the document as a real HTML table.
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted');
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
// The offer chrome from the shared renderer is intact.
assert.ok(html.includes('<!doctype html>'));
});
test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |');
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
});
+5
View File
@@ -31,6 +31,11 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
+101
View File
@@ -0,0 +1,101 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk).
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
+2
View File
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
+81
View File
@@ -0,0 +1,81 @@
apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
// it unset, so the release APK is produced UNSIGNED rather than failing assembleDebug and
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
// otherwise so it is never referenced with a null storeFile.
if (hasKeystore) {
storeFile file(keystoreFile)
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ANDROID_KEY_ALIAS')
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
// of failing.
if (hasKeystore) {
signingConfig signingConfigs.release
}
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+20
View File
@@ -0,0 +1,20 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-network')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,26 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -0,0 +1,5 @@
package ru.eruditgame.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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