Compare commits

..

95 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
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 2683103fc1 Merge pull request 'fix(deploy): provision the certs dir traversable by the nonroot gateway' (#238) from fix/ansible-certs-dir-traversable 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 1m10s
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
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) Has been skipped
2026-07-10 10:39:06 +00:00
Ilia Denisov 2d2dd2bc47 fix(deploy): provision the certs dir traversable by the nonroot gateway
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 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The Ansible base-directory loop created /opt/scrabble/certs at mode 0750
(deploy:deploy), like config/dumps/images. The gateway (and backend) run as
the distroless nonroot UID 65532 — not the deploy user — so the container
cannot traverse a 0750 certs dir and fails at startup with
"mtls: load server keypair: ... permission denied", crash-looping.

This is latent: a long-running container holds the keypair in memory and
never re-reads the file, so the misconfig only bites when a container
restarts (a host reboot / redeploy). A hoster maintenance reboot exposed it
on prod — the gateway came back crash-looping while the deploy could not SSH
in mid-reboot.

Split certs out of the 0750 loop and create it 0755 (traversable). The keys
stay 0644 by design (the gateway compose relies on it); the host is
single-tenant + SSH-access-controlled, so a traversable certs dir adds no
meaningful exposure. The live prod host was already chmod-fixed by hand; this
keeps the next provisioning run from re-tightening it.
2026-07-10 12:34:21 +02:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer df9eace09f Merge pull request 'fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback' (#236) from fix/prod-robokassa-wiring into development
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 20s
CI / conformance (push) Successful in 10s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 11s
CI / ui (push) Successful in 1m10s
CI / gate (push) Successful in 0s
CI / ui (pull_request) Successful in 1m10s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-10 10:04:03 +00:00
Ilia Denisov 0a0a9e5a8d fix(deploy): wire the Robokassa direct rail into the prod deploy + rollback
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 27s
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
The Robokassa credentials reached only the test contour (ci.yaml → TEST_
secrets). The prod-deploy / prod-rollback workflows and write-prod-env.sh
never rendered BACKEND_ROBOKASSA_*, so on prod the shop login was empty and
the direct RUB rail stayed disabled — the PROD_BACKEND_ROBOKASSA_* secrets
went nowhere.

Export the shop login + Password1/Password2 (secrets) and a
BACKEND_ROBOKASSA_TEST flag (a variable, so go-live is a flag flip not a
secret rotation) from both prod workflows, and emit the four ROBOKASSA_*
vars from write-prod-env.sh (the shared deploy/rollback env renderer) so a
rollback keeps the rail up. The compose already maps ROBOKASSA_* →
BACKEND_ROBOKASSA_*. Document the new secrets/variable in the deploy README
and .env.example. Password3 (Robokassa's JWT-invoice API) is unused.
2026-07-10 11:59:20 +02:00
developer 0c9678c42b Merge pull request 'feat(ui): per-kind active-game limit lock on the New Game screen' (#235) from feature/e8-guest-limits-client into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 28s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 09:11:40 +00:00
Ilia Denisov e40adfb0c7 fix(games): carry game kind on live events + fix the New Game lock funnel
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Address review of the active-game limit lock:

- Live-event GameViews (opponent_moved, match_found, game_started, ...) did
  not carry game_kind, so a lobby patch from an event zeroed a game's kind
  and the client's per-kind count under-counted — a capped kind read as
  free (a disabled start instead of the lock, and a wrong per-kind result).
  Thread Kind through notify.GameSummary → the event GameView.

- New Game screen refreshes the lobby games on mount so the per-kind count
  reflects the current set, not a stale cached snapshot.

- The guest funnel's login button routes to the profile screen (the account
  controls), not settings.

- Copy: the guest prompt is "sign in to use all the game's features"; the
  durable notice is "finish your active games to start a new one".

Regression tests: the notify opponent-moved payload carries kind; the client
lock locks only the reached kind (vs_ai at cap leaves random open).
2026-07-10 10:55:48 +02:00
Ilia Denisov 3306a016a0 feat(ui): per-kind active-game limit lock on the New Game screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 28s
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 1m46s
Carry the caller's per-tier active-game caps and each game's kind on the
wire (Profile.game_limits + GameView.kind, additive FBS + gateway transcode
+ client codec, committed regen). The New Game screen counts the player's
active games per kind from the lobby and locks a capped start: an outline
button with a lock that opens a funnel modal instead of a game -- a sign-in
prompt for a guest, a "finish a current game first" notice for a signed-in
account (native Telegram popup, in-app modal elsewhere). The lock lifts via
the existing profile refetch after a guest->durable upgrade.

Remove the lobby's old at_game_limit New-Game tab disable + notice: the flag
(now the random-kind cap) conflicted with the per-kind lock -- it hid the
screen where the lock lives and wrongly blocked an unfulfilled kind. The New
Game tab is always enabled; the per-kind start lock is the only gate. The
at_game_limit wire field stays (unused by the client) for a later cleanup.

Tests: client lock logic + codec kind/game_limits roundtrip + gateway
transcode encode + native popup builders (unit); a mock e2e for the lock
badge and the modal.
2026-07-10 10:09:45 +02:00
developer e45167041f Merge pull request 'feat(backend): per-tier, per-kind active-game limits with a guest funnel' (#234) from feature/e8-guest-limits into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 1s
CI / deploy (push) Successful in 1m56s
2026-07-10 07:17:54 +00:00
Ilia Denisov ed53e25e57 feat(backend): per-tier, per-kind active-game limits with a guest funnel
CI / changes (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m41s
Cap a player's simultaneous unfinished games per kind (vs_ai, random,
friends) with independent guest and durable-account tiers, held in a new
single-row backend.config table (-1 = unlimited) behind an in-memory cache
and editable live in the admin console (/_gm/limits). Each game is tagged
with games.game_kind on creation.

This replaces the earlier flat MaxActiveQuickGames=10 combined cap: the
per-tier/kind config is the single mechanism, enforced at the same handler
gate (ensureUnderGameLimit by kind on lobby/enqueue) plus the durable
friends cap in CreateInvitation. game.Service.AtGameLimit only resolves the
tier and counts; the limit policy stays at the request edge.

Guests are now refused friend requests, friend-code redemption,
befriend-in-game and invitation creation outright (403 guest_forbidden) --
previously only the UI hid these.

Admin: a kind column in both game lists and the config editor.

Defaults: guest 1 vs_ai / 1 random / 0 friends; durable 10 / 10 / 10.
2026-07-10 09:03:57 +02:00
developer 2e5136b22a Merge pull request 'feat(admin): manual full-order refund + ledger CSV export' (#233) from feature/e7-refund-export into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 19s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m5s
2026-07-10 04:52:49 +00:00
Ilia Denisov 1bf612a087 feat(admin): manual full-order refund + ledger CSV export
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Closes the admin / reports / catalog work. Each fund row on the /_gm finance
panel gains a Refund action (payments.RefundOrderFull): a full-order refund the
operator records after refunding on the rail — a refund ledger row + a floor-0
chip revoke (never negative, D27), idempotent (a second refund reports
already-refunded). A ledger CSV export (/_gm/ledger.csv, payments.LedgerExport)
streams the whole append-only ledger for tax + reconciliation.

Tests: refund an order in full (chips revoked, a refund row), an idempotent
second refund, the CSV export shape; CSRF-guarded.
2026-07-10 06:46:18 +02:00
developer ec5c6afa23 Merge pull request 'feat(admin): admin grant — raw benefits and by-product reward bundles' (#232) from feature/e7-admin-grant into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
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-10 04:37:43 +00:00
Ilia Denisov 82648a4398 fix(game): show granted/bought hints in-game — finish the D31 wire removal
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
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 1m58s
The in-game hint badge ignored the payments hint wallet: loading a game clobbered
app.profile.hintBalance with the deprecated StateView.wallet_balance (zeroed in the
D31 domain removal but left in the protocol as a dead 0, then synced over the real
balance at game load).

Finish the removal honestly. StateView carries the per-game allowance alone
(hints_remaining); the purchasable wallet lives solely on the profile and the client
adds it (lib/hints). Removed StateView.wallet_balance across every layer — backend
StateView/DTO, notify.PlayerState (live events), gateway StateResp + wire.StateView,
the client model/codec/mock/localgame — and dropped the now-unused wallet arg from
hintsRemaining. The FBS field is tombstoned `(deprecated)` (not deleted) so the vtable
slots after it stay stable across a rolling deploy; no accessor is generated. HintResult
keeps wallet_balance (the real post-spend payments balance the client adopts into the
profile). The StateView type no longer has walletBalance, so the clobber cannot return
without a compile error.

Tests: hintsRemaining (2-arg); hints.hintsLeft (allowance + live wallet, no strip); the
game state/hint integration (allowance-only HintsRemaining); gateway transcode; FBS regen.
2026-07-10 06:26:49 +02:00
Ilia Denisov d2d6955cbf feat(admin): admin grant — raw benefits and by-product reward bundles
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) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
The /_gm user card gains a Grant panel: grant raw benefit atoms (hints /
no-ads days / forever) or a defined value product (a reward bundle, including
an archived one), origin-picked. Both write an admin_grant ledger row via
payments.Grant / GrantProduct; the by-product grant records the source
product_id + snapshot. Both refuse a chips atom (never grant currency) or a
tournament atom (no credit target yet); chips/tournament products are also
kept out of the by-product picker.

Tests: the console grant end to end (raw, by-product, refuse a chips pack,
CSRF-guarded).
2026-07-10 05:35:55 +02:00
developer c3eecf16b3 Merge pull request 'feat(admin): product catalog editor' (#231) from feature/e7-catalog-editor into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 03:26:26 +00:00
Ilia Denisov 0036b55618 feat(admin): product catalog editor
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) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The /_gm console gains a Catalog editor — the source of truth for products.
Create / edit / archive-unarchive products, their atom composition and per-rail
prices (RUB via direct / VOTE via vk / XTR via telegram / CHIP value), and
hard-delete only a never-transacted product (an order or ledger reference forces
archive-only, backed by the FK RESTRICT). The archived flag reuses the existing
product.active. Activation revalidates the sellable shape — a pack (the chips
atom ⇒ a money price per rail, chips-only) or a value (no chips ⇒ a CHIP price);
a tournament-bearing product is composable but never sellable yet.

Backed by payments AdminCatalog / CreateProduct / UpdateProduct /
SetProductActive / DeleteProduct + a pure validateProduct.

Tests: validateProduct (pack / value / tournament / duplicate / shape); the
console editor end to end (create, edit, archive, delete-if-clean, refuse a
transacted delete).
2026-07-10 05:18:54 +02:00
developer aabb32081e Merge pull request 'feat(admin): per-user finance panel — balances, benefits, risk, ledger' (#230) from feature/e7-financial-panel into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 11s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-10 03:03:45 +00:00
Ilia Denisov e089a0a997 feat(admin): per-user finance panel — balances, benefits, risk, ledger
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 28s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m46s
The /_gm user card gains a Finance panel: an account's chip balances per
funding segment, benefits per origin (hints, no-ads until/forever), the
recorded refund risk (abuse flag + floor-0 loss), and the append-only ledger
history newest-first. Backed by a new payments.AccountStatement read straight
from the materialized tables + the ledger (uncached — an admin, rare view).

Folds the agreed admin / reports / catalog plan into PLAN.md (the PR stack:
this panel, then the catalog editor, admin grant, refund + ledger export) and
moves the tournament-entry storage design to the tournament stage.
2026-07-10 04:55:55 +02:00
developer 00d1bd33e3 Merge pull request 'feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account' (#229) from feature/ads-interstitial 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 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
2026-07-10 01:48:10 +00:00
Ilia Denisov 7ce8101cfa test(ads): lock the hint-pushed ad restarting the vs_ai gap (no over-serving)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
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 1m57s
2026-07-10 03:43:57 +02:00
Ilia Denisov b5c8a04f0b fix(ads): share one interstitial cooldown timer across kinds
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
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 1m53s
Cooldowns were tracked per kind ({move,hint}), so a hint ad and a move ad
did not see each other: after a hinted move's ad the move timer was still
zero, and the next plain move fired an ad immediately — two ads within a
minute, under the cooldown. Track a single shared last-shown time; the kind
only selects the required gap (hint 1m, move 5m, vs_ai 30m) measured from the
last interstitial of any kind. A hint can still fire on its shorter gap
(D30's "independent" hint cooldown), but never stacks a second ad within a
cooldown.

Tests: a hint uses the shorter shared gap; a move does not stack onto a
just-shown hint ad.
2026-07-10 03:36:18 +02:00
Ilia Denisov 2ce80c241d fix(ads): fire the hint interstitial on the confirmed move, not on the hint
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 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m48s
Taking a hint fired the post-move interstitial immediately, when the hint's
preview tiles landed — interrupting the turn and reverting the board to the
rack on the ad's close while the hint stayed spent. Move the trigger to the
move confirmation: a hint applied this turn marks it (hintUsedThisTurn), and
the confirmed play fires the hint-kind interstitial (its own cooldown)
instead of the plain move one; the marker clears on any turn boundary
(applyMoveResult) so a hint-then-pass does not leak into the next move.

e2e: taking a hint fires no ad; confirming a move does.
2026-07-10 03:12:30 +02:00
Ilia Denisov 13be7c3d9a feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline
banner-only. The gate is client-mirrored: the backend puts the config
cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the
no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor;
the client self-gates on a per-kind last-shown time in localStorage, with
the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass,
exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec
and gateway transcode carry the ads block.

Also retires the deprecated accounts.hint_balance / paid_account domain
usage (expand-contract, code only — the columns stay for a later DROP so
image rollback stays DB-safe): drop the Account fields and their scan, the
dead account.SpendHint, account.GrantHints and the admin grant-hints
action; the in-game hint display now comes wholly from the payments hint
benefit. Banner eligibility and account merge no longer read the legacy
flags.

Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip,
the gateway ads transcode test, and a profile ads-config integration test
(cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru)
interstitial, the decision amends, backend README, the plan.
2026-07-10 02:48:10 +02:00
developer e08d3301bd Merge pull request 'feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps' (#228) from feature/ads-rewarded into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m9s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-09 23:33:10 +00:00
Ilia Denisov 9acf6ab3b4 fix(payments): VK-iOS freeze is purchase-only; remove the rewarded diagnostic
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 1m9s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m5s
Testing the rewarded slice surfaced a contradiction: rewarded ads let a VK-iOS
user earn vk chips, but the blanket VK-iOS "spend freeze" then blocked spending
them (the wallet showed "5 (view-only)"). Apple's ToS forbids only BUYING in-app
values on VK-iOS (money -> chips), not spending or earning them — so the freeze is
corrected to purchase-only: vkFrozen() now gates only CreateOrder (the money-in
step), not spendableSources (spending). VK-wallet chips — earned via rewarded ads
or bought on the same account elsewhere (VK Android) — now spend on VK-iOS too.
(Owner's ToS finding.)

Also: the temporary contour diagnostic confirmed VK returns only {result:true} for
a rewarded view (no token/signature) — client-attested is final, no hardening
possible — so the diagnostic (the log and the diag wire field) is removed.

Docs: PAYMENTS(+ru) VK-iOS freeze + D17 amend + PLAN E6. Tests updated (gate /
wallet VK-iOS now spendable).
2026-07-10 01:27:26 +02:00
Ilia Denisov dbd76d53e8 feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
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 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads
(VKWebAppShowNativeAds) expose only a client-side watch result — no
server-to-server verify — so the credit is client-attested, guarded by a server
daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10).
The caps are both anti-abuse (bounding a forger who skips the ad and calls the
endpoint directly) and an economic conversion lever (limiting free chips so a
player who wants more buys). D29 amended to VK's reality.

Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored
by the caps; payout from config rewarded_payout_chips, default 0 = off) + the
wallet.reward edge op returning the updated wallet (reward_chips gates the
"watch for chips" CTA). Additive migration (two config columns).

Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge
(vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub
(VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary
diagnostic that logs the raw VK data, so we confirm on the contour exactly what
VK returns (harden to signature-verify if it carries one).

Tests: backend integration (credit, nonce idempotency, hourly cap, disabled,
non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend,
PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
2026-07-10 00:41:42 +02:00
developer 68c937f3b6 Merge pull request 'feat(payments): refund engine (best-effort revoke, never negative)' (#227) from feature/payment-intake-refunds into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
2026-07-09 21:26:33 +00:00
Ilia Denisov 21fb14facf feat(payments): refund engine (best-effort revoke, never negative)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m45s
The last money-intake slice: reverse a paid order best-effort, exactly once. All
refunds are admin-triggered (E7) — no rail pushes an unsolicited refund (Robokassa
via its refund API / cabinet, VK via support, Telegram via refundStarPayment), so
this ships the engine they all converge on, not a webhook.

The Refund method matches the paid order, appends a refund ledger row (idempotent on
(provider, provider_refund_id) — distinct from the fund's payment id, so both
coexist), and revokes the funded chips floored at 0 (never negative — D27,
balances_chips_chk). When the chips were already spent, the unrecoverable remainder
is recorded as a per-account loss + abuse flag in the new additive
payments.account_risk table (read by the E7 report). The refund ledger row's chip
delta is what was actually reclaimed (the ledger stays reconcilable); the full
reversal rides in the snapshot; the order stays paid.

Additive migration (a new table only) -> rollback-safe, no contour wipe. Robokassa
refund-status polling is deferred (a worker not worth it at low chargeback volume);
failed events are not wired (no rail signals a hard post-charge server decline).

Tests: integration (full revoke; revoke-after-spend = floor-0 + loss + abuse;
duplicate idempotent; unpaid-order guard). Docs: PAYMENTS(+ru) §9, PLAN (E5 -> DONE).
2026-07-09 23:21:10 +02:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
341 changed files with 17461 additions and 2179 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
+20 -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 }}
@@ -407,6 +412,9 @@ jobs:
# the VK ID redirect URL is derived from PUBLIC_BASE_URL in the run step below.
VITE_VK_APP_LINK: ${{ vars.VITE_VK_APP_LINK }}
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
# Rewarded-ad test stub: set TEST_VITE_ADS_STUB=1 to swap real ads for a toast on the
# contour (empty = real ads, for capturing the real VK ad result). Prod never sets it.
VITE_ADS_STUB: ${{ vars.TEST_VITE_ADS_STUB }}
# VITE_GATEWAY_URL omitted: the SPA is served same-origin, so it stays the
# compose ":-" empty default. Other unset vars likewise fall to their defaults.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
@@ -507,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
+19
View File
@@ -99,14 +99,33 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail (backend BACKEND_ROBOKASSA_*): the prod shop login + the pass phrases
# that sign the launch request / verify the Result callback, and the test-mode flag — a var so
# go-live is a flag flip, not a secret redeploy ("1" runs test payments against the test
# passwords; empty/"0" is live). An empty login leaves the direct rail disabled.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
# VK ID web login: the "Web" app id (the gateway reuses it as GATEWAY_VK_ID_APP_ID at
# runtime) + the app's protected key. Both shared across contours. The redirect URL is
# 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
+10
View File
@@ -61,9 +61,19 @@ jobs:
# the SAME env.sh (email / VK login / Grafana alerts survive a rollback). TELEGRAM_MINIAPP_URL
# and GRAFANA_ROOT_URL are derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
GATEWAY_VK_APP_SECRET: ${{ secrets.GATEWAY_VK_APP_SECRET }}
# Robokassa direct-rail: the rollback re-renders the same runtime env (write-prod-env.sh), so
# it must carry the same credentials or the direct rail goes dark after a rollback.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.PROD_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.PROD_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: ${{ vars.PROD_BACKEND_ROBOKASSA_TEST }}
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
+223 -61
View File
@@ -34,10 +34,10 @@ status — without re-deriving decisions.
| E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | WIP |
| E6 | Ads | 2 | TODO |
| E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO |
| E5 | Payment intake | 2 | DONE |
| E6 | Ads | 2 | DONE |
| E7 | Admin, reports & catalog | 2 | DONE |
| E8 | Guest limits | — | DONE |
| E9 | Tournament fee | future | TODO |
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
@@ -504,7 +504,7 @@ maintenance window). Migrations stay expand-contract so image rollback remains D
## E5 — Payment intake
**Status:** WIP · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Status:** DONE · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
@@ -534,8 +534,18 @@ order account's language. A completed `successful_payment` is persisted to a pur
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
in the admin is the go-live. Remaining: refunds; and hiding the ad banner on a no-ads purchase (a
spend-path `NotifyBanner`, deferred with the owner's agreement).
in the admin is the go-live. Finally **refunds** are delivered on `feature/payment-intake-refunds`: a
single `Refund` engine (`internal/payments`) reverses a paid order best-effort, exactly once —
idempotent on `(provider, provider_refund_id)`, revoking the funded chips **floored at 0** (never
negative, D27), and recording the unrecoverable remainder (chips already spent) as a per-account
**loss + abuse flag** in the new additive `payments.account_risk` table (read by the E7 report). The
refund ledger row's chip delta is what was actually reclaimed (the ledger stays reconcilable); the
full reversal rides in its snapshot; the order stays `paid`. **No rail pushes an unsolicited refund**
— all are admin-triggered (E7): Robokassa refund API / cabinet (auto-polling deferred — a worker not
worth it at low chargeback volume), VK via support, Telegram `refundStarPayment`. `failed` events are
not wired (no rail signals a hard post-charge server decline). The migration is **additive** (a new
table only), so E5 stays rollback-safe / no contour wipe. That closes E5. Deferred to a later stage:
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, with the owner's agreement).
**Goal.** Accept real money on all three rails into the payments domain: order-flow,
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
@@ -577,9 +587,13 @@ receipts, and refunds.
**Receipts (§12).** Robokassa self-employed НПД receipt on payment (provider config); VK
handles Votes tax itself; TG Stars — no receipt.
**Refunds (§9).** ToS non-refundable; admin manual refund (ties to `accountdelete`); external
`refunded` events honoured — best-effort benefit revoke (never negative; record loss + abuse
flag if spent), ledger `refund` row. Ledger export-ready (reconciliation not built).
**Refunds (§9).** ToS non-refundable. **All refunds are admin-triggered** (E7): no rail pushes an
unsolicited refund — Robokassa refund API / cabinet (auto-polling deferred as a low-value worker),
VK via support, Telegram `refundStarPayment`. One `Refund` engine reverses a paid order best-effort,
exactly once (idempotent on `(provider, provider_refund_id)`): revoke floored at 0 (never negative),
unrecoverable remainder → per-account loss + abuse flag (`payments.account_risk`), a `refund` ledger
row (chip delta = revoked, full reversal in the snapshot). `failed` events are not wired (no rail
signals a hard post-charge server decline). Ledger export-ready (reconciliation not built).
**Tests.**
@@ -604,9 +618,42 @@ force-recreate when the Caddyfile changes.
## E6 — Ads
**Status:** TODO · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
**Status:** DONE · **Release 2** · depends on: E2 (chips), E5 (rewarded credits via intake) ·
mechanics: PAYMENTS §10.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice): **rewarded first**,
then interstitial. Baked: the interstitial cooldowns already exist in `payments.config` (E0) and the
per-origin banner suppression is already done (E2 `AdFree`), so E6 is the two ad DISPLAY paths + the
rewarded credit. **VK reality (checked live in the VK docs via Playwright):** VK Mini App ads
(`VKWebAppShowNativeAds`, both `reward` and `interstitial`) expose **only a client-side `data.result`
boolean** — no server verify, no signature. So **D29 is amended**: rewarded is **client-attested**,
guarded by a server **daily + hourly cap** (config `reward_daily_cap` / `reward_hourly_cap`, default
50 / 10) that is both anti-abuse and an economic conversion lever (limits free chips so players buy);
the cooldown state for the interstitial is **client-mirrored** (owner's pick). Delivered on
`feature/ads-rewarded` (the **rewarded** slice): the ads-network abstraction (`ui/src/lib/ads.ts`, VK
impl) + the VK bridge (`vkRewardedReady` / `vkShowRewarded`), the backend `CreditReward` (VK-only,
order-less, idempotent on a client nonce, floored by the caps, payout from config
`rewarded_payout_chips` default 0 = off), the `wallet.reward` edge op returning the updated wallet
(with `reward_chips` gating the "watch for chips" CTA), and a **contour test stub** (`VITE_ADS_STUB` →
a toast instead of a real ad; prod always real). A temporary diagnostic confirmed on the contour that
VK returns **only `{result:true}`** (no token/signature) — client-attested is final, no hardening
possible; the diagnostic is removed. The slice also **corrects the VK-iOS freeze to purchase-only**
(rewarded on VK-iOS earns chips, which the old blanket "spend freeze" then blocked from spending —
Apple forbids only *buying* in-app values, not spending or earning them; `vkFrozen()` now gates only
`CreateOrder`, not `spendableSources`, so VK-wallet chips spend on VK-iOS). Delivered on
`feature/ads-interstitial` (the **interstitial + D31** slice): the post-move fullscreen interstitial
as a **client-mirrored** gate — the backend `adsFor` puts the config cooldowns + a `suppressed` flag
(the no-ads / `no_banner` gate, same as the banner) on the profile (`Profile.ads`), and
`ui/src/lib/ads.ts` `maybeShowInterstitial` self-gates on the last-shown time per kind in
`localStorage`, showing a VK interstitial (`vkShowInterstitial`) after a **confirmed play or a hint
only** (never a pass / exchange / resign), VK-only, offline banner-only, with the same `VITE_ADS_STUB`
toast on the contour. The slice also lands **D31 step 1 (contract-code)**: the domain no longer reads
or writes the deprecated `accounts.hint_balance` / `paid_account` columns — the `Account` fields, the
dead `account.SpendHint`, `account.GrantHints` and the admin **grant-hints** action are removed, and
the in-game hint display now comes wholly from the payments benefit (`HintsAvailable`). The **columns
stay** (no migration → image rollback is DB-safe); a later contract-PR does the `DROP` once E6 is
stable on prod.
**Goal.** VK video ads: the post-move interstitial (frequency-gated) and the rewarded video
(credits chips via server verify), plus extending the existing banner suppression to
per-origin.
@@ -621,8 +668,9 @@ per-origin.
- **Interstitial** (post-move fullscreen), configurable server values (from `payments`
config): global per-user cooldown across all games (default 5 min); `vs_ai` 30 min; a hint
application triggers a post-move interstitial independently with its own 1-min cooldown;
offline banner-only; respect VK's own frequency caps. Cooldown state tracked server-side
(per user) or client-mirrored from a server value — pick and document at implementation.
offline banner-only; respect VK's own frequency caps. Cooldown state is **client-mirrored**
(the chosen option): the server sends the cooldowns + `suppressed` on the profile and the
client self-gates on a per-kind last-shown time in `localStorage` — no per-move round-trip.
- **Banner suppression:** extend `ads.Eligible` (`backend/internal/ads/ads.go` :107) to gate
on the **origin benefit applicable in the current context** (E2 interface) instead of the
single legacy flag. No-ads suppresses banner + interstitial; rewarded never suppressed.
@@ -645,75 +693,177 @@ it tunes without a store release.
---
## E7 — Admin & reports
## E7 — Admin, reports & catalog
**Status:** TODO · **Release 2** · depends on: E2 (ledger/grant), E5 (payments/refunds) ·
mechanics: PAYMENTS §11.
**Status:** DONE · **Release 2** · depends on: E2 (ledger/grant/spend), E5 (payments/refunds),
E6 (D31 retired the legacy `hint_balance`/`paid_account`) · mechanics: PAYMENTS §11, §12, D32.
**Goal.** The admin console financial surface: per-user report, admin grant UI, manual
refund UI, ledger export.
**Delivery & baked decisions (this planning round).** A linear PR stack into `development`.
**Work (`/_gm`, `backend/internal/server/handlers_admin_console.go` + `adminconsole/`).**
- **Archived product = the existing `product.active` flag** (no new column, no migration):
`active=false` **is** "archived". Its three behaviours already hold — hidden from the user
storefront (`store_catalog.go` filters `active`), an **in-flight external payment still
credits** (the `fund` credit path resolves the order and never re-checks `active`; only order
*creation* / chip *spend* require `active`), and a product with any order/ledger row **cannot
be hard-deleted** (FK `orders→product` / `ledger→product` are RESTRICT). The admin toggle is
labelled **Archive / Unarchive**.
- **Delete vs archive:** the editor offers a hard **Delete** only for a product with **no
orders and no ledger rows** (never transacted — the FK is the DB backstop); a transacted
product is **archive-only**.
- **Admin grant = raw atoms + by-product.** Keep the quick raw-atom grant (N hints / no-ads
days / forever) AND add grant-by-product: pick a defined product (including archived "reward"
bundles) and grant its atoms. Both write an `admin_grant` ledger row; by-product records
`product_id` + the snapshot. **Both refuse any set containing `chips`** (admin never grants
currency) **or `tournament`** (no credit target until E9) — an explicit refusal, never a
silent no-op.
- **Manual refund = full order only** for now (the E5 `Refund` engine takes an amount; partial
is a later add if needed).
- **Tournament stays atom-only (E0); its entry economy is E9.** The catalog editor can compose
products carrying the `tournament` atom (archived templates for E9), but granting/spending a
`tournament` atom is refused until E9 designs the storage (recurring types, each its own
benefit + price) — see E9. Adding a `benefits.tournament` counter now was rejected: the model
is multi-type, so a single column would be wrong and force a second DB break.
- The admin grant **no longer mirrors `grant-hints`** (E6/D31 removed that action); it is a
fresh action on `payments.Grant`.
- **Per-user financial panel** on the existing user card (`consoleUserDetail` :343,
`UserDetailView`): segment balances, payments, spends, grants, refunds, full history —
read from the append-only ledger + materialized cache.
- **Admin grant** action (mirror the existing `POST /_gm/users/:id/grant-hints`): grant
concrete values (no-ads days / hints), **origin picker**, **never chips**; writes an
`admin_grant` ledger row (E2 `Grant`).
- **Manual refund** action: admin-initiated refund of a specific order (ties to the refund
path); records a `refund` ledger row; best-effort benefit revoke.
- **Ledger export**: CSV/JSON export of the ledger for tax reporting + future Robokassa
reconciliation (export-ready schema; reconciliation itself not built).
**Goal.** The admin console financial surface — per-user report, admin grant, manual refund,
ledger export — plus the **configurable product catalog editor** (D32).
**Work (`/_gm`, `handlers_admin_console.go` + `adminconsole/`, `internal/payments/`).**
- **Per-user financial panel** on the user card (`consoleUserDetail`, `UserDetailView`): segment
chip balances `(account, source)`, benefits `(account, origin)` (hints, no-ads until/forever),
and the full append-only ledger (fund/spend/admin_grant/refund — amount, origin, product,
provider, snapshot) from the ledger + materialized cache. Replaces the retired
`PaidAccount`/`HintBalance` fields with the segmented view.
- **Catalog editor** (`/_gm/catalog`): list every product (active + archived) with its atoms and
per-method/currency prices; create/edit (title, atom items `atom_type→quantity`, price rows
`method+currency→amount`); **Archive/Unarchive** (`active`); **Delete** (never-transacted
only). Enforce the projection's shape: a **pack** (carries `chips`) needs a money price per
method; a **value** (no `chips`) needs a single `CHIP` price. A `tournament`-bearing product is
allowed in composition but cannot be activated for sale until E9.
- **Admin grant** action: raw atoms (hints / no-ads days / forever) and by-product (a value
product, incl. archived); **origin picker**; refuses `chips`/`tournament`; `admin_grant` ledger
row (+ `product_id`/snapshot for by-product) via `payments.Grant`.
- **Manual refund** action: refund a specific paid order **in full** — a `refund` ledger row +
best-effort floor-0 benefit revoke (E5 `Refund`).
- **Ledger export**: CSV/JSON for tax + future Robokassa reconciliation (export-ready;
reconciliation itself not built).
- Auth unchanged: gateway Basic-Auth in front of `/_gm` + backend same-origin CSRF on POSTs.
**Tests.**
- unit: report view assembly; grant refuses chips; export shape.
- integration: grant/refund write correct ledger rows; report reflects balances+history.
- unit: panel view assembly; catalog pack/value shape projection; grant refuses chips + tournament;
editor validation (pack⇒money price, value⇒CHIP price); export shape.
- integration: grant (raw + by-product) writes the right `admin_grant` row + benefit; refund writes
a `refund` row + floor-0 revoke; catalog CRUD round-trips (create→edit→archive→delete-if-clean);
delete refused on a transacted product; panel reflects balances + benefits + history.
**Done-criteria.** An operator can see a user's full financial picture, grant concrete
values (origin-picked, never chips), issue a manual refund, and export the ledger.
**Done-criteria.** An operator can: see a user's full financial picture; create/edit/archive
products + prices and delete only never-transacted ones; grant concrete values raw or by-product
(origin-picked, never chips/tournament); refund an order in full; export the ledger.
**Notes/risks.** `/_gm` per-user detail already surfaces `PaidAccount`/`HintBalance` — replace
those with the segmented view as the legacy columns retire.
**PR stack (linear into `development`, all merged).**
1. ~~**Per-user financial panel**~~ (#230) — read-only ledger/segments/benefits on the user card;
retired the `PaidAccount`/`HintBalance` display.
2. ~~**Catalog editor**~~ (#231) — product/atom/price CRUD + archive/unarchive + delete-if-clean +
shape validation.
3. ~~**Admin grant**~~ (#232, + the D31 hint-wallet wire cleanup that surfaced there) — raw +
by-product, refuse chips/tournament, origin-picked, `admin_grant` + snapshot.
4. ~~**Manual refund** (full order via E5 `Refund`) + **ledger CSV export**~~ — `RefundOrderFull`
(idempotent, floor-0 revoke) on each fund row; `/_gm/ledger.csv`.
**Notes/risks.** High-blast-radius (money, ledger — append-only, trigger-enforced). No mixed-in
refactors. The catalog editor becomes the source of truth for products; the contour SQL seeds
become bootstrap-only.
---
## E8 — Guest limits
**Status:** TODO · **standalone** (game-behaviour change; can run in parallel) · depends on:
none · mechanics: PAYMENTS §6.
**Status:** DONE · **standalone** (game-behaviour change) · depends on: none · mechanics: PAYMENTS §6.
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today the
gating is UI-only).
**Goal.** A registration funnel: cap what a guest can do, enforced **server-side** (today UI-only),
with configurable per-tier × per-kind limits so the pressure tunes without a release.
**Finding (verified).** The friend/invitation paths do **not** check `is_guest` on the
server — only the UI hides them: `social/friends.go:50` `SendFriendRequest`,
`robotfriends.go:41` `RequestInGame`, `friendcodes.go:67` `RedeemFriendCode`,
`lobby/invitations.go:208` `CreateInvitation`. A guest with a valid `X-User-ID` can call them
in the UI's stead.
**Finding (verified).** Two gaps. (a) The friend/invitation paths do **not** check `is_guest` on the
server — only the UI hides them: `social/friends.go`, `robotfriends.go`, `friendcodes.go`,
`lobby/invitations.go`. A guest with a valid `X-User-ID` can call them. (b) A **pre-existing** flat
cap already existed: `game.MaxActiveQuickGames`=10 — a **combined** cap on `active`+`open` quick
games (vs_ai+random together, friend games excluded), counted by `CountActiveQuickGames`, enforced at
the handler (`ensureUnderGameLimit`) with **409 `game_limit_reached`**, surfaced as `at_game_limit`.
E8's per-tier × per-kind config **subsumes and replaces** it (decided: option A).
**Delivery & baked decisions (this planning round).** A linear PR stack; E8 is game-behaviour, no
payments mixed in.
- **`games.game_kind smallint DEFAULT 0`** (0=unknown — pre-E8 games, never gated; 1=vs_ai; 2=random;
3=friends), set on creation (`StartVsAI`=1, `Enqueue`=2, a friend invitation=3). Existing games
stay 0 and fall outside the gate.
- **Limits are per-tier × per-kind**, in a **new single-row `backend.config`** table:
`guest_{vs_ai,random,friends}_limit` + `durable_{vs_ai,random,friends}_limit` (smallint,
**`-1`=unlimited**), seeded `(1,1,0, 10,10,10)`. A guest is capped at 1 vs_ai + 1 random (friends
is moot — the guest gate blocks friend games); a durable account is **10 per kind** — the old flat
MaxActiveQuickGames=10, now split per kind. Editable in the admin.
- **The old flat cap is removed** (option A, owner-agreed): `MaxActiveQuickGames`,
`CountActiveQuickGames`, `atGameLimit` deleted; the per-tier/kind config is the single mechanism,
enforced at the **same handler gate** (`ensureUnderGameLimit(kind)` for `enqueue`
random/vs_ai) plus the durable **friends cap** inside `CreateInvitation`. The gate stays out of the
game domain — `game.Service.AtGameLimit(account, kind)` only resolves tier + counts.
- **A hot in-memory cache** fronts the config (read once on start, invalidated on the admin edit —
mirrors the payments read-cache) so a login / game-create never queries it.
- **"Active" = `open` + `active`** (an open, unmatched random game holds a slot); the limit is on
**creation** — an existing game is grandfathered, never interrupted.
- **Limits ride the wire (forward-compat, no double break):** `Profile.game_limits`
(`GameLimits{vs_ai, random, friends}` — the caller's tier resolved server-side) + `GameView.kind`,
so the client counts active games **per kind** from its lobby and locks the right start button. On
a guest → durable upgrade (register / link) the client **re-fetches the profile** so the new
(durable) limits apply. The client picks the lock's message by tier: a **guest** → the login funnel;
a **durable** account at its cap → a plain "finish a current game first" notice.
**Work.**
- **Server guest gate** on friend-request / redeem-code / invitation-create: refuse when the
caller `is_guest` (add an `ErrGuestForbidden`-style guard, as `feedback` already has).
- **Active-game limits** for guests: at most **1 random-opponent game + 1 vs_ai** concurrently
(enforce on game/invitation creation in `lobby`/`game`; today no such limit exists).
- Keep the existing UI gating; this adds the server as the source of truth.
- ~~**PR1 — backend + admin (server-side)** — DONE.~~ The migration (`game_kind` + `backend.config`,
seeded `1,1,0 / 10,10,10` + jetgen); `game_kind` set on creation and projected onto `game.Game`;
the **guest gate** (`ErrGuestForbidden`, mapped to **403 `guest_forbidden`**) on friend-request /
redeem-code / befriend-in-game / invitation-create; the **active-limit enforce** — the old flat
`MaxActiveQuickGames` mechanism removed and replaced by `ensureUnderGameLimit(kind)` on `enqueue`
(random/vs_ai) plus the durable friends cap in `CreateInvitation`, keyed off
`game.Service.AtGameLimit`; the **`internal/gamelimits` config + hot cache** (loaded at boot,
invalidated on edit); the admin **kind column** in both game lists (`/_gm/games` + the user card)
and a **config editor** (`/_gm/limits`) for the six limits.
- ~~**PR2 — wire + client** — DONE.~~ `Profile.game_limits` (the caller's tier) + `GameView.kind` (FBS
+ gateway transcode + client codec, committed regen); the client counts active games per kind from
the lobby cache (`gamelimits.ts`) and locks a capped new-game start — an **outline 🔒** button that
opens `GameLimitModal` instead of a game, native (Telegram `showPopup`) or the in-app `Modal`
elsewhere, with two messages by tier: a **guest** sign-in funnel ("Войдите или создайте учётную
запись…", Отмена / Вход→`/settings`) and, for a **durable** account at its cap, a plain notice
("Вы достигли лимита одновременных игр, сначала завершите текущие", ОК). The lock lifts via the
existing profile re-fetch after a guest→durable upgrade.
- **Decision (owner-agreed): the lobby's old `at_game_limit` New-Game tab-disable + notice is
removed.** The `at_game_limit` flag (now the random-kind cap) conflicted with the per-kind lock —
it hid the New-Game screen where the lock lives, and wrongly blocked starting an unfulfilled kind.
The tab is always enabled; the per-kind lock on the start button is the only gate. The wire field
`GameList.at_game_limit` stays (unused by the client) for a later cleanup.
**Tests.**
- integration: guest is refused friend/redeem/invitation server-side; guest blocked from a
2nd random / 2nd vs_ai; durable account unaffected.
- UI: guest sees the funnel (existing hides + any new messaging).
- integration (PR1, done): `game_kind` persisted per path; guest refused friend/redeem/invitation
(domain + HTTP 403); guest blocked from a 2nd vs_ai / 2nd random (+ `at_game_limit`); durable on the
higher tier; the durable friends cap + config cache reflecting an admin edit; accept stays exempt.
- unit (PR1, done): the per-tier/kind limit resolution (`Cap`, `LimitsFor`).
- unit + UI (PR2, done): the client lock logic (`gamelimits.ts` — count/cap/lock), the codec kind +
game_limits roundtrip, the gateway transcode game_limits encode, the popup builders, and a mock e2e
(`gamelimit.spec.ts`) — a capped start shows 🔒, opens the modal without navigating, and the lock
clears when the profile refetch lifts the cap.
**Done-criteria.** Guests are server-limited to 1 random + 1 vs_ai and cannot friend/invite;
durable accounts unchanged; regression that this does not break existing durable flows.
**Done-criteria.** A guest is server-capped (default 1 vs_ai + 1 random, configurable) and cannot
friend/invite; a durable account is capped at 10 per kind (configurable); the client shows the lock +
the tier-appropriate modal and the cap lifts on registration; durable flows otherwise unchanged.
**Notes/risks.** This changes existing game behaviour — its own tests, its own PR, no
mixed-in payments changes. Decide whether a guest may finish an already-started game (limit
on creation, not mid-game) at implementation and record it here.
**Notes/risks.** Game-behaviour change — own PRs, own tests, no payments mixed in. The limit is on
creation (existing games grandfathered). The config cache is single-instance (matching the deploy).
---
@@ -722,11 +872,23 @@ on creation, not mid-game) at implementation and record it here.
**Status:** TODO · **future** · depends on: E0 (atom provisioned), tournament feature ·
mechanics: PAYMENTS §5, §7.
**Goal.** Charge a chip entry fee for tournaments. The `tournament` atom + a catalog product
are provisioned in E0/E7; the spend mechanic reuses E2 (`Spend`). The actual tournament
feature and its coupling to entry are out of scope until the tournament feature exists.
**Goal.** A chip entry economy for tournaments. The `tournament` atom is provisioned (E0) and
E7's catalog editor can compose tournament products; E7 deliberately **defers the entry storage
+ credit/spend** here, to avoid guessing the schema.
**Done-criteria.** Deferredrevisit when tournaments are built.
**Design to settle here (not before — avoid a double DB break).** Tournaments are expected in
**several recurring types** (daily / weekly / monthly …), each its **own benefit with its own
price** — so a single `benefits.tournament` counter is wrong. Model the entry store as
**per-tournament-type** (e.g. a `tournament_type` catalog + a per-`(account, type)` entry
balance), design the pricing, then:
- lift E7's **refusal** of granting/spending the `tournament` atom (admin grant by-product +
chip spend);
- add the **spend** (charge an entry) reusing E2 `Spend`;
- wire the coupling to the actual tournament feature (out of scope until it exists).
**Done-criteria.** Deferred — revisit when tournaments are built; this stage owns the
tournament-entry storage + pricing design.
---
+33 -11
View File
@@ -33,11 +33,16 @@ real game seating the caller with an **empty opponent seat** (status `open`) or,
another player already waits for the same variant and per-turn word rule, seats the
caller into that open game and starts it — and
friend-game invitations (invite → accept, starting a 24 player game once every
invitee accepts). A **simultaneous-game cap** (`game.MaxActiveQuickGames` = 10) limits a
player's active quick games — status `active`/`open`, excluding invitation-linked friend
games (`game.Service.CountActiveQuickGames`); the server refuses `lobby/enqueue` and
`invitations` creation with **409 `game_limit_reached`** at the cap (accepting an invitation
is exempt), and the `games.list` response carries an `at_game_limit` flag for the lobby.
invitee accepts). **Per-tier, per-kind active-game caps** limit a player's simultaneous
unfinished games by kind — `vs_ai`, `random` (quick auto-match), `friends` — with separate
guest and durable-account tiers held in the single-row `backend.config` table (a `-1` means
unlimited), read through an in-memory cache (`internal/gamelimits`) and tuned live in the admin
(`/_gm/limits`, no redeploy). Each game is tagged with its `games.game_kind` on creation;
`game.Service.AtGameLimit` counts the account's `active`/`open` games of a kind against the
tier's cap. The server refuses `lobby/enqueue` (random/vs_ai) with **409 `game_limit_reached`**
at the cap, and the `invitations` (friends) path enforces the durable friends cap and refuses a
**guest** outright (guests cannot use friends); accepting an invitation is exempt. The
`games.list` response carries an `at_game_limit` flag (the random-kind cap) for the lobby.
`internal/social` owns the friend graph (request/accept),
per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers,
@@ -135,21 +140,38 @@ so `/internal/push-target` returns the recipient's `preferred_language` as the r
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
&& !no_banner` role, the message language picked by `preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
the resolved, weighted campaign feed for an **eligible** viewer (no active **no-ads** benefit
applicable in the current context and no **`no_banner`** role; the message language picked by
`preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place.
The same gate drives the post-move interstitial config (`Profile.ads`, `adsFor`). The user card
also carries a **finance panel** (`payments.AccountStatement`): the account's chip balances per
funding segment, benefits per origin, the recorded refund risk, and the append-only ledger history
(newest first) — read straight from the payments tables, uncached. The **catalog editor**
(`/_gm/catalog`, `handlers_admin_catalog.go`) is the source of truth for products (D32): create /
edit / archive-unarchive (the `product.active` flag) products, their atoms and per-rail prices, and
hard-delete only a **never-transacted** product (an order/ledger reference forces archive-only,
backed by the FK); a `tournament`-bearing product is composable but not sellable yet. The user card
also carries an admin **grant** panel: grant raw benefit atoms (hints / no-ads days / forever) or a
defined **value product** (a reward bundle, including an archived one), origin-picked; both write an
`admin_grant` ledger row via `payments.Grant` / `GrantProduct` and **refuse** a chips or `tournament`
atom (never grant currency; no tournament target yet). Each fund row in the panel carries a **Refund**
action (`payments.RefundOrderFull`): a full-order refund the operator records after refunding on the
rail — a `refund` ledger row + a floor-0 chip revoke, idempotent. A **ledger CSV export**
(`/_gm/ledger.csv`, `payments.LedgerExport`) dumps the whole append-only ledger for tax +
reconciliation. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module.
**Account linking & merge** (`/api/v1/user/link/*`). `internal/link`
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
attached to the current account, and when the identity already has its own account
the two are merged in one transaction (`internal/accountmerge`) — stats and the hint
wallet summed, `paid_account` ORed, identities/games/chat/complaints transferred,
the two are merged in one transaction (`internal/accountmerge`) — stats summed,
identities/games/chat/complaints transferred,
friends/blocks de-duplicated, the secondary kept as a `merged_into` tombstone (so a
shared finished game's foreign keys hold); a shared **active** game blocks the merge.
The current account is primary, except a guest initiator whose linked identity has a
durable owner — then the durable account wins and a fresh session is minted for it.
The `accounts.paid_account`/`merged_into`/`merged_at` columns back this. This supersedes the
The `accounts.merged_into`/`merged_at` columns back this. This supersedes the
former `email.bind.*` edge surface (the `RequestCode`/`ConfirmCode` primitives stay).
Rate-limit observability: the gateway posts its periodic rejection
+20
View File
@@ -28,6 +28,7 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
@@ -156,6 +157,17 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("active dictionary version", zap.String("version", games.ActiveVersion()))
games.SetNotifier(hub)
games.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/game"))
// Active-game limit config: the per-tier, per-kind caps in backend.config, read once into an
// in-memory cache at boot and refreshed when the admin edits them. A boot-time load fails fast if
// the single config row is missing; the game domain reads the cache on every new-game gate.
gameLimits := gamelimits.NewService(gamelimits.NewStore(db))
if err := gameLimits.Load(ctx); err != nil {
return fmt.Errorf("load game-limit config: %w", err)
}
games.SetGameLimits(gameLimits)
logger.Info("game-limit config loaded")
go games.RunSweeper(ctx, cfg.Game.TimeoutSweepInterval)
logger.Info("game turn-timeout sweeper started",
zap.Duration("interval", cfg.Game.TimeoutSweepInterval))
@@ -271,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.
@@ -305,6 +324,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
BanView: banView,
Ads: adsSvc,
Payments: paymentsSvc,
GameLimits: gameLimits,
Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
+1 -56
View File
@@ -43,9 +43,7 @@ var ErrNotFound = errors.New("account: not found")
// local-time window (in TimeZone) during which the player is asleep, so the
// turn-timeout sweeper does not auto-resign them inside it. (The robot opponent's
// own sleep is anchored to its human opponent's timezone with a per-game drift,
// computed in internal/robot, not from a robot account's away window.) HintBalance
// is the player's wallet of purchasable hints, spent after a game's per-seat
// allowance.
// computed in internal/robot, not from a robot account's away window.)
type Account struct {
ID uuid.UUID
DisplayName string
@@ -53,7 +51,6 @@ type Account struct {
TimeZone string
AwayStart time.Time
AwayEnd time.Time
HintBalance int
BlockChat bool
BlockFriendRequests bool
// VariantPreferences is the set of game variants (engine.Variant stable labels:
@@ -69,10 +66,6 @@ type Account struct {
// true (the default): the platform side-service skips out-of-app push for the
// account.
NotificationsInAppOnly bool
// PaidAccount marks a lifetime one-time-payment account. It is a service field
// (no purchase flow yet); an account linking & merge ORs it so a paid status is
// never lost when accounts are consolidated.
PaidAccount bool
// MergedInto is the primary account a retired (merged) secondary points at, or
// uuid.Nil for a live account. A tombstone keeps the row so the no-cascade
// foreign keys of a shared finished game stay valid.
@@ -563,52 +556,6 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
return modelToAccount(row), nil
}
// SpendHint atomically decrements the account's hint wallet by one, returning
// true when a hint was spent and false when the balance was already empty. The
// guarded UPDATE keeps it safe under concurrent spends across the player's games.
func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.SUB(postgres.Int(1)), postgres.TimestampzT(time.Now().UTC())).
WHERE(
table.Accounts.AccountID.EQ(postgres.UUID(id)).
AND(table.Accounts.HintBalance.GT(postgres.Int(0))),
)
res, err := stmt.ExecContext(ctx, s.db)
if err != nil {
return false, fmt.Errorf("account: spend hint %s: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("account: spend hint rows %s: %w", id, err)
}
return n > 0, nil
}
// GrantHints adds n hints to the account's wallet and returns the new balance. n must be
// positive: the additive update can only raise the balance, never lower it, so it enforces the
// admin console's raise-only rule by construction and stays correct under a concurrent SpendHint.
// It returns ErrNotFound when no account matches.
func (s *Store) GrantHints(ctx context.Context, id uuid.UUID, n int) (int, error) {
if n <= 0 {
return 0, fmt.Errorf("account: grant hints %s: n must be positive, got %d", id, n)
}
stmt := table.Accounts.
UPDATE(table.Accounts.HintBalance, table.Accounts.UpdatedAt).
SET(table.Accounts.HintBalance.ADD(postgres.Int(int64(n))), postgres.TimestampzT(time.Now().UTC())).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.HintBalance)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("account: grant hints %s: %w", id, err)
}
return int(row.HintBalance), nil
}
// FlagHighRate stamps the soft "suspected high-rate" marker with at, only when
// the account is not already flagged — the first sustained episode wins, and a
// re-flag after an operator clear starts a fresh timestamp. An infra marker, not
@@ -664,12 +611,10 @@ func modelToAccount(row model.Accounts) Account {
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest,
NotificationsInAppOnly: row.NotificationsInAppOnly,
PaidAccount: row.PaidAccount,
MergedInto: mergedInto,
FlaggedHighRateAt: flaggedHighRateAt,
CreatedAt: row.CreatedAt,
@@ -15,12 +15,14 @@
<a href="/_gm/"{{if eq .ActiveNav "dashboard"}} class="active"{{end}}>Dashboard</a>
<a href="/_gm/users"{{if eq .ActiveNav "users"}} class="active"{{end}}>Users</a>
<a href="/_gm/games"{{if eq .ActiveNav "games"}} class="active"{{end}}>Games</a>
<a href="/_gm/limits"{{if eq .ActiveNav "limits"}} class="active"{{end}}>Limits</a>
<a href="/_gm/complaints"{{if eq .ActiveNav "complaints"}} class="active"{{end}}>Complaints</a>
<a href="/_gm/feedback"{{if eq .ActiveNav "feedback"}} class="active"{{end}}>Feedback</a>
<a href="/_gm/messages"{{if eq .ActiveNav "messages"}} class="active"{{end}}>Messages</a>
<a href="/_gm/throttled"{{if eq .ActiveNav "throttled"}} class="active"{{end}}>Throttled</a>
<a href="/_gm/reasons"{{if eq .ActiveNav "reasons"}} class="active"{{end}}>Reasons</a>
<a href="/_gm/banners"{{if eq .ActiveNav "banners"}} class="active"{{end}}>Banners</a>
<a href="/_gm/catalog"{{if eq .ActiveNav "catalog"}} class="active"{{end}}>Catalog</a>
<a href="/_gm/dictionary"{{if eq .ActiveNav "dictionary"}} class="active"{{end}}>Dictionary</a>
<a href="/_gm/broadcast"{{if eq .ActiveNav "broadcast"}} class="active"{{end}}>Broadcast</a>
<a href="/_gm/grafana/">Grafana ↗</a>
@@ -0,0 +1,44 @@
{{define "content" -}}
<h1>Product catalog</h1>
{{with .Data}}
<p class="note">A <strong>pack</strong> funds chips (a money price per rail — RUB via direct, VOTE via vk, XTR via telegram); a <strong>value</strong> buys benefits with chips (a CHIP price). Archived products are hidden from players but still credit an in-flight payment and can be granted. A product with transactions can only be archived, not deleted. Amounts are in minor units (RUB kopecks; VOTE/XTR/CHIP whole). The <code>tournament</code> atom is not sellable yet — keep such a product archived.</p>
<section class="panel"><h2>Add product</h2>
<form class="form col" method="post" action="/_gm/catalog">
<label>Title <input type="text" name="title" maxlength="120" required></label>
<fieldset><legend>Atoms (quantity; blank = none)</legend>
<label>Chips <input type="number" name="chips" min="0"></label>
<label>Hints <input type="number" name="hints" min="0"></label>
<label>No-ads days <input type="number" name="noads" min="0"></label>
<label>Tournament <input type="number" name="tournament" min="0"></label>
</fieldset>
<fieldset><legend>Prices (minor units; blank = none)</legend>
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0"></label>
<label>VOTE — vk <input type="number" name="price_vote" min="0"></label>
<label>XTR — telegram <input type="number" name="price_star" min="0"></label>
<label>CHIP — value <input type="number" name="price_chip" min="0"></label>
</fieldset>
<label><input type="checkbox" name="active" value="true"> Active (on sale)</label>
<div><button type="submit">Add</button></div>
</form>
</section>
<section class="panel"><h2>Products</h2>
<table class="list">
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
<tbody>
{{range .Products}}
<tr>
<td><a href="/_gm/catalog/{{.ID}}">{{.Title}}</a></td>
<td>{{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</td>
<td>{{range .Atoms}}<code>{{.Atom}}×{{.Quantity}}</code> {{end}}</td>
<td>{{range .Prices}}<code>{{.Currency}}{{if .Method}}/{{.Method}}{{end}} {{.Amount}}</code> {{end}}</td>
<td class="row-actions">
<form class="form" method="post" action="/_gm/catalog/{{.ID}}/archive"><input type="hidden" name="active" value="{{if .Active}}false{{else}}true{{end}}"><button type="submit">{{if .Active}}Archive{{else}}Unarchive{{end}}</button></form>
{{if not .Transacted}}<form class="form" method="post" action="/_gm/catalog/{{.ID}}/delete" onsubmit="return confirm('Delete this product? It has never been transacted, so this is safe and permanent.')"><button type="submit">Delete</button></form>{{end}}
</td>
</tr>
{{else}}<tr><td colspan="5"><span class="note">no products</span></td></tr>{{end}}
</tbody>
</table>
</section>
{{end}}
{{- end}}
@@ -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>
@@ -8,11 +8,11 @@
<a href="/_gm/games?status=finished"{{if eq .Status "finished"}} class="active"{{end}}>finished</a>
</nav>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th>🤖</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Items}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td>{{if .VsAI}}🤖{{end}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="7"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
<nav class="pager">
@@ -0,0 +1,19 @@
{{define "content" -}}
<h1>Active-game limits</h1>
{{with .Data}}
<p class="note">Per-tier, per-kind caps on a player's simultaneous unfinished games. <strong>-1</strong> = unlimited, <strong>0</strong> = the kind is blocked, a positive number caps concurrent games of that kind. Guests are additionally blocked from friend games outright. Changes apply immediately (no redeploy); games already in progress are never affected.</p>
<section class="panel">
<form class="form col" method="post" action="/_gm/limits">
<h2>Guest</h2>
<label>vs AI <input type="number" name="guest_vs_ai" min="-1" value="{{.GuestVsAI}}" required></label>
<label>Random <input type="number" name="guest_random" min="-1" value="{{.GuestRandom}}" required></label>
<label>Friends <input type="number" name="guest_friends" min="-1" value="{{.GuestFriends}}" required></label>
<h2>Durable account</h2>
<label>vs AI <input type="number" name="durable_vs_ai" min="-1" value="{{.DurableVsAI}}" required></label>
<label>Random <input type="number" name="durable_random" min="-1" value="{{.DurableRandom}}" required></label>
<label>Friends <input type="number" name="durable_friends" min="-1" value="{{.DurableFriends}}" required></label>
<div><button type="submit">Save</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -0,0 +1,25 @@
{{define "content" -}}
{{with .Data}}
<p class="note"><a href="/_gm/catalog">← all products</a></p>
<h1>{{.Title}} {{if .Active}}<span class="ok">active</span>{{else}}<span class="warn">archived</span>{{end}}{{if .Transacted}} <span class="pill">transacted</span>{{end}}</h1>
<section class="panel"><h2>Edit</h2>
<p class="note">A zero quantity / blank price removes that atom / price. Amounts are in minor units. Saving revalidates the sellable shape when the product is active. Archive / unarchive from the <a href="/_gm/catalog">catalog list</a>.</p>
<form class="form col" method="post" action="/_gm/catalog/{{.ID}}">
<label>Title <input type="text" name="title" value="{{.Title}}" maxlength="120" required></label>
<fieldset><legend>Atoms (quantity; 0 = none)</legend>
<label>Chips <input type="number" name="chips" min="0" value="{{.Chips}}"></label>
<label>Hints <input type="number" name="hints" min="0" value="{{.Hints}}"></label>
<label>No-ads days <input type="number" name="noads" min="0" value="{{.NoAds}}"></label>
<label>Tournament <input type="number" name="tournament" min="0" value="{{.Tournament}}"></label>
</fieldset>
<fieldset><legend>Prices (minor units; 0 = none)</legend>
<label>RUB — direct (kopecks) <input type="number" name="price_rub" min="0" value="{{.PriceRUB}}"></label>
<label>VOTE — vk <input type="number" name="price_vote" min="0" value="{{.PriceVote}}"></label>
<label>XTR — telegram <input type="number" name="price_star" min="0" value="{{.PriceStar}}"></label>
<label>CHIP — value <input type="number" name="price_chip" min="0" value="{{.PriceChip}}"></label>
</fieldset>
<div><button type="submit">Save</button></div>
</form>
</section>
{{end}}
{{- end}}
@@ -10,8 +10,6 @@
<li><b>Timezone</b> {{.TimeZone}}</li>
<li><b>Guest</b> {{if .Guest}}yes{{else}}no{{end}}</li>
<li><b>Push</b> {{if .NotificationsInAppOnly}}in-app only{{else}}out-of-app{{end}}</li>
<li><b>Paid</b> {{if .PaidAccount}}yes{{else}}no{{end}}</li>
<li><b>Hint wallet</b> {{.HintBalance}}</li>
{{if .MergedInto}}<li><b>Merged into</b> {{.MergedInto}}</li>{{end}}
{{if .FlaggedHighRateAt}}<li><b>High-rate flag</b> <span class="warn">{{.FlaggedHighRateAt}}</span></li>{{end}}
<li><b>Created</b> {{.CreatedAt}}</li>
@@ -21,10 +19,6 @@
<button type="submit">Clear high-rate flag</button>
</form>
{{end}}
<form class="form" method="post" action="/_gm/users/{{.ID}}/grant-hints">
<label>Add hints <input type="number" name="amount" min="1" max="{{.HintGrantMax}}" value="1"></label>
<button type="submit">Grant</button>
</form>
</section>
<section class="panel"><h2>Statistics</h2>
{{if .HasStats}}
@@ -69,6 +63,51 @@
<div><button type="submit">{{if .Suspension.Blocked}}Re-block{{else}}Block{{end}}</button></div>
</form>
</section>
<section class="panel"><h2>Finance</h2>
{{if .Finance.Present}}
{{if or .Finance.Segments .Finance.Benefits .Finance.Abuse .Finance.Loss}}
<ul class="kv">
{{range .Finance.Segments}}<li><b>Chips ({{.Source}})</b> {{.Chips}}</li>{{end}}
{{range .Finance.Benefits}}<li><b>Benefits ({{.Origin}})</b> {{.Hints}} hints{{if .Forever}} · no-ads forever{{else if .AdsUntil}} · no-ads until {{.AdsUntil}} (UTC){{end}}</li>{{end}}
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
</ul>
{{else}}<p class="note">no balances or benefits</p>{{end}}
<h3>Ledger</h3>
{{$uid := .ID}}
{{if .Finance.Ledger}}
<table class="list">
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
<tbody>
{{range .Finance.Ledger}}
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
{{end}}
</tbody>
</table>
<p class="note"><a href="/_gm/ledger.csv">Export the full ledger (CSV)</a> — all accounts, for tax + reconciliation.</p>
{{else}}<p class="note">no ledger entries</p>{{end}}
{{else}}<p class="note">payments not enabled</p>{{end}}
</section>
<section class="panel"><h2>Grant benefits</h2>
{{if .Grant.Present}}
<p class="note">A zero-price admin sale of a value — <strong>never chips</strong>. The origin is your compliance choice. The by-product grant applies a defined bundle, including an archived reward product.</p>
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant">
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<label>Hints <input type="number" name="hints" min="0" value="0"></label>
<label>No-ads days <input type="number" name="noads" min="0" value="0"></label>
<label><input type="checkbox" name="forever" value="true"> No-ads forever</label>
<div><button type="submit">Grant</button></div>
</form>
{{if .Grant.Products}}
<h3>Grant a product</h3>
<form class="form col" method="post" action="/_gm/users/{{.ID}}/grant-product">
<label>Origin <select name="origin">{{range .Grant.Origins}}<option value="{{.}}">{{.}}</option>{{end}}</select></label>
<label>Product <select name="product_id">{{range .Grant.Products}}<option value="{{.ID}}">{{.Title}} ({{.Summary}}){{if .Archived}} — archived{{end}}</option>{{end}}</select></label>
<div><button type="submit">Grant product</button></div>
</form>
{{else}}<p class="note">no grantable products — create a value product in the <a href="/_gm/catalog">catalog</a></p>{{end}}
{{else}}<p class="note">payments not enabled</p>{{end}}
</section>
<section class="panel"><h2>Roles</h2>
{{$id := .ID}}
{{if .Roles}}
@@ -172,11 +211,11 @@
{{end}}
<section class="panel"><h2>Games</h2>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<thead><tr><th>Game</th><th>Variant</th><th>Kind</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
<tbody>
{{range .Games}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="5"><span class="note">no games</span></td></tr>{{end}}
<tr><td><a href="/_gm/games/{{.ID}}">{{.ID}}</a></td><td>{{.Variant}}</td><td>{{.Kind}}</td><td>{{.Status}}</td><td class="num">{{.Players}}</td><td>{{.UpdatedAt}}</td></tr>
{{else}}<tr><td colspan="6"><span class="note">no games</span></td></tr>{{end}}
</tbody>
</table>
</section>
+138 -11
View File
@@ -149,7 +149,6 @@ type UserDetailView struct {
TimeZone string
Guest bool
NotificationsInAppOnly bool
PaidAccount bool
// MergedInto is the primary account id when this account has been retired by a
// merge, or empty for a live account.
MergedInto string
@@ -165,14 +164,10 @@ type UserDetailView struct {
// FlaggedHighRateAt is the pre-formatted soft high-rate marker timestamp,
// empty for an unflagged account; the card shows it with the Clear action.
FlaggedHighRateAt string
HintBalance int
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
// server's maxHintGrant), passed through so the policy value lives in one place.
HintGrantMax int
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
// HasEmail gates the "Erase email" action; set when the account carries an email identity.
HasEmail bool
Games []GameRow
@@ -200,6 +195,55 @@ type UserDetailView struct {
Blocks []RelationRow
BlockedBy []RelationRow
Friends []RelationRow
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
// is false when the payments domain is unwired.
Finance FinanceView
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
// payments domain is unwired.
Grant GrantFormView
}
// FinanceView is the account's payments picture on the user card: chip balances per funding
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
// (newest first). Present is false when the payments domain is unwired.
type FinanceView struct {
Present bool
Segments []SegmentRow
Benefits []BenefitRow
// Abuse is the refund abuse flag; Loss is the unrecoverable chip loss from floor-0 refunds.
Abuse bool
Loss int
Ledger []LedgerRow
}
// SegmentRow is one funding segment's chip balance.
type SegmentRow struct {
Source string
Chips int
}
// BenefitRow is one origin's benefit: the hint wallet, the ad-free expiry (pre-formatted, empty
// when none) and the lifetime ad-free flag.
type BenefitRow struct {
Origin string
Hints int
AdsUntil string
Forever bool
}
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
// the pre-formatted time.
type LedgerRow struct {
Kind string
Source string
Origin string
ChipsDelta int
Product string
Order string
Provider string
Snapshot string
At string
}
// RelationRow is one cross-linked account in the user card's blocks / blocked-by / friends
@@ -268,6 +312,8 @@ type GameRow struct {
UpdatedAt string
// VsAI marks an honest-AI game (rendered as 🤖 in the list's AI column).
VsAI bool
// Kind is the game's origin tag label: vs_ai / random / friends / unknown.
Kind string
}
// GamesView is the paginated games list, optionally filtered by status.
@@ -277,6 +323,17 @@ type GamesView struct {
Pager Pager
}
// GameLimitsView is the per-tier, per-kind active-game limit form: each field is a cap where -1
// is unlimited, 0 blocks the kind, and a positive value caps concurrent games of that kind.
type GameLimitsView struct {
GuestVsAI int
GuestRandom int
GuestFriends int
DurableVsAI int
DurableRandom int
DurableFriends int
}
// GameDetailView is one game with its seats.
type GameDetailView struct {
ID string
@@ -291,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
@@ -612,3 +673,69 @@ type FeedbackDetailView struct {
UserTZ string
Banned bool
}
// CatalogView is the product-catalog list page.
type CatalogView struct {
Products []ProductRow
}
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
// (Active) and the transacted flag (which forbids a hard delete).
type ProductRow struct {
ID string
Title string
Active bool
Atoms []AtomRow
Prices []PriceRow
Transacted bool
}
// AtomRow is one atom line of a product row.
type AtomRow struct {
Atom string
Quantity int
}
// PriceRow is one price of a product: the method ("" for a value's CHIP price), the currency, and
// the amount in that currency's minor units.
type PriceRow struct {
Method string
Currency string
Amount int64
}
// ProductFormView is the product edit form, pre-filled from the current composition. Atom quantities
// and prices are flattened to the fixed fields the form offers (0 = absent); Transacted disables the
// delete action.
type ProductFormView struct {
ID string
Title string
Active bool
Chips int
Hints int
NoAds int
Tournament int
PriceRUB int64
PriceVote int64
PriceStar int64
PriceChip int64
Transacted bool
}
// GrantFormView is the admin-grant panel on the user card: the origin picker and the grantable
// products (value bundles — hints / no-ads days — including archived ones; chips and tournament
// products are excluded). Present is false when the payments domain is unwired.
type GrantFormView struct {
Present bool
Origins []string
Products []GrantProductOption
}
// GrantProductOption is one grantable product in the by-product picker: its id, title, an atom
// summary, and whether it is archived (the common case for a non-public reward bundle).
type GrantProductOption struct {
ID string
Title string
Summary string
Archived bool
}
+1 -1
View File
@@ -52,6 +52,7 @@ func gameSummary(g Game, names []string) notify.GameSummary {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
@@ -74,7 +75,6 @@ func playerState(v StateView, names []string, includeAlphabet bool) (notify.Play
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
}
if includeAlphabet {
tab, err := engine.AlphabetTable(v.Game.Variant)
+8 -8
View File
@@ -55,16 +55,16 @@ func TestPayloadExchangeRoundTrip(t *testing.T) {
}
func TestHintsRemaining(t *testing.T) {
cases := []struct{ allowance, used, wallet, want int }{
{1, 0, 3, 4},
{1, 1, 3, 3},
{1, 2, 3, 3}, // used past allowance clamps to 0
{0, 0, 5, 5},
{2, 1, 0, 1},
cases := []struct{ allowance, used, want int }{
{1, 0, 1},
{1, 1, 0},
{1, 2, 0}, // used past allowance clamps to 0
{3, 1, 2},
{0, 0, 0},
}
for _, c := range cases {
if got := hintsRemaining(c.allowance, c.used, c.wallet); got != c.want {
t.Errorf("hintsRemaining(%d,%d,%d) = %d, want %d", c.allowance, c.used, c.wallet, got, c.want)
if got := hintsRemaining(c.allowance, c.used); got != c.want {
t.Errorf("hintsRemaining(%d,%d) = %d, want %d", c.allowance, c.used, got, c.want)
}
}
}
+49 -23
View File
@@ -17,6 +17,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/session"
@@ -57,6 +58,9 @@ type Service struct {
// nil, only the free per-game allowance is served (no purchased hints). vs_ai hints are
// wallet-free and never touch it.
hintWallet HintWallet
// limits is the per-tier active-game cap config (cached). Set by SetGameLimits during wiring;
// when nil, no active-game limit is enforced (a game is always creatable).
limits *gamelimits.Service
// clearNudges, when set, marks the actor's pending nudges in a game read once they
// have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package.
@@ -126,6 +130,37 @@ func (svc *Service) SetHintWallet(w HintWallet) {
svc.hintWallet = w
}
// SetGameLimits installs the active-game limit config (cached), enabling the per-tier caps. When
// unset (nil), a game is always creatable.
func (svc *Service) SetGameLimits(l *gamelimits.Service) {
svc.limits = l
}
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind — the
// per-tier, per-kind limits held in backend.config, read from the in-memory cache. It resolves
// the caller's tier (guest vs durable) from the account, then counts its open+active games of that
// kind. It reports false (not at the limit) when the limits config is not wired, the account is nil,
// or the resolved cap is gamelimits.Unlimited. It backs the new-game gate (the handler aborts 409
// game_limit_reached) and the lobby's at-limit flag.
func (svc *Service) AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error) {
if svc.limits == nil || accountID == uuid.Nil {
return false, nil
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return false, err
}
limit := svc.limits.LimitsFor(acc.IsGuest).Cap(kind)
if limit == gamelimits.Unlimited {
return false, nil
}
n, err := svc.store.CountActiveByKind(ctx, accountID, kind)
if err != nil {
return false, err
}
return n >= limit, nil
}
// walletContext resolves the payments gate inputs for an account on the current request: the
// trusted execution context (from the session platform carried on ctx; absent ⇒ untrusted) and
// the account's present identity sources (which chip/benefit segments are awake, §6).
@@ -338,6 +373,7 @@ func (svc *Service) Create(ctx context.Context, params CreateParams) (Game, erro
dropoutTiles: params.DropoutTiles.String(),
multipleWordsPerTurn: params.MultipleWordsPerTurn,
vsAI: params.VsAI,
kind: params.Kind,
}
if err := svc.store.CreateGame(ctx, ins, seats, seeding.draws); err != nil {
return Game{}, err
@@ -401,6 +437,7 @@ func (svc *Service) OpenOrJoin(ctx context.Context, accountID uuid.UUID, params
multipleWordsPerTurn: params.MultipleWordsPerTurn,
status: StatusOpen,
openDeadline: &deadline,
kind: gamelimits.KindRandom,
}
// Decide the first move now by the official draw, with the not-yet-arrived opponent as a
// synthetic placeholder (uuid.Nil): the draw fixes who sits at seat 0 — and so moves
@@ -1207,7 +1244,7 @@ func (svc *Service) Hint(ctx context.Context, gameID, accountID uuid.UUID) (Hint
return HintResult{}, err
}
used++
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used, walletAfter), WalletBalance: walletAfter}, nil
return HintResult{Move: move, HintsRemaining: hintsRemaining(pre.HintsPerPlayer, used), WalletBalance: walletAfter}, nil
}
// Candidates returns the to-move player's legal plays for a seated player on
@@ -1272,10 +1309,6 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
if !ok {
return StateView{}, ErrNotAPlayer
}
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return StateView{}, err
}
unlock := svc.locks.lock(gameID)
defer unlock()
@@ -1290,12 +1323,13 @@ func (svc *Service) GameState(ctx context.Context, gameID, accountID uuid.UUID)
}
}
return StateView{
Game: pre,
Seat: seat,
Rack: g.Hand(seat),
BagLen: g.BagLen(),
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed, acc.HintBalance),
WalletBalance: acc.HintBalance,
Game: pre,
Seat: seat,
Rack: g.Hand(seat),
BagLen: g.BagLen(),
// HintsRemaining is the per-seat allowance only; the purchasable wallet lives on the profile
// (payments) and the client adds it (lib/hints.hintsLeft).
HintsRemaining: hintsRemaining(pre.HintsPerPlayer, pre.Seats[seat].HintsUsed),
// vs_ai idle-hint gate (seconds left; 0 for a human game / first move / not your turn).
HintUnlockLeftSeconds: hintUnlockLeftSeconds(pre, seat, svc.clock()),
}, nil
@@ -1389,14 +1423,6 @@ func (svc *Service) ListForLobby(ctx context.Context, accountID uuid.UUID) ([]Ga
return kept, nil
}
// CountActiveQuickGames reports how many in-progress quick games the account holds —
// the count the simultaneous-game limit (MaxActiveQuickGames) is checked against. It
// counts active and still-open quick games (including honest-AI ones) and excludes
// friend games created by invitation and finished games. See Store.CountActiveQuickGames.
func (svc *Service) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
return svc.store.CountActiveQuickGames(ctx, accountID)
}
// HideGame hides a finished game from accountID's own lobby (it stays visible to the other
// players); it is irreversible by design. Only a player of a finished game may hide it
// (ErrNotAPlayer / ErrGameActive otherwise); hiding an already-hidden game is a no-op.
@@ -1776,10 +1802,10 @@ func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, e
return svc.registry.DictBytes(variant, version)
}
// hintsRemaining is a player's remaining hint budget: the unspent per-game
// allowance plus the profile wallet.
func hintsRemaining(allowance, used, wallet int) int {
return max(0, allowance-used) + wallet
// hintsRemaining is the unspent per-game hint allowance. The purchasable wallet is separate,
// carried on the profile (payments), and the client adds it (lib/hints.hintsLeft).
func hintsRemaining(allowance, used int) int {
return max(0, allowance-used)
}
// allowedTimeout reports whether d is one of the offered move clocks.
+20 -24
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
@@ -45,6 +46,8 @@ type gameInsert struct {
multipleWordsPerTurn bool
// vsAI marks an honest-AI game (games.vs_ai).
vsAI bool
// kind tags the game's origin (games.game_kind) for the active-game limits.
kind gamelimits.Kind
// status is the lifecycle state to create the game in: StatusActive for a normal
// seated game, StatusOpen for an auto-match game still awaiting an opponent. An
// empty string defaults to StatusActive.
@@ -138,6 +141,20 @@ func (s *Store) CreateGame(ctx context.Context, ins gameInsert, seats []seatInse
})
}
// CountActiveByKind counts the account's active (open or in-progress) games of the given kind — the
// per-tier active-game limit is checked against it before a new game of that kind is created.
func (s *Store) CountActiveByKind(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (int, error) {
var n int
if err := s.db.QueryRowContext(ctx,
`SELECT count(*) FROM backend.games g
JOIN backend.game_players p ON p.game_id = g.game_id
WHERE p.account_id = $1 AND g.game_kind = $2 AND g.status IN ('open', 'active')`,
accountID, int16(kind)).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active by kind %s: %w", accountID, err)
}
return n, nil
}
// insertGameTx inserts the games row and one game_players row per seat (seat 0
// first) on tx, stamping each seat's display-name snapshot. A seat whose account id is
// uuid.Nil is written with a NULL account_id (and an empty snapshot) — the still-empty
@@ -155,9 +172,9 @@ func insertGameTx(ctx context.Context, tx *sql.Tx, ins gameInsert, seats []seatI
table.Games.GameID, table.Games.Variant, table.Games.DictVersion, table.Games.Seed,
table.Games.Status, table.Games.Players, table.Games.TurnTimeoutSecs,
table.Games.HintsAllowed, table.Games.HintsPerPlayer, table.Games.OpenDeadlineAt,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi,
table.Games.DropoutTiles, table.Games.MultipleWordsPerTurn, table.Games.VsAi, table.Games.GameKind,
).VALUES(ins.id, ins.variant, ins.dictVersion, ins.seed, status, ins.players,
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI)
ins.turnTimeoutSecs, ins.hintsAllowed, ins.hintsPerPlayer, deadline, ins.dropoutTiles, ins.multipleWordsPerTurn, ins.vsAI, int16(ins.kind))
if _, err := gi.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("insert game: %w", err)
}
@@ -501,28 +518,6 @@ func (s *Store) ListGamesForAccount(ctx context.Context, accountID uuid.UUID) ([
return out, nil
}
// CountActiveQuickGames counts the account's in-progress quick games — the ones the
// simultaneous-game limit (MaxActiveQuickGames) is checked against. It includes both
// active and still-open (awaiting-opponent) games, the honest-AI ones among them, and
// excludes friend games (those linked to a game_invitations row) and finished games.
// A hidden game still occupies a slot, so this is a dedicated count rather than a
// filter over ListGamesForAccount (which drops hidden games). Joining on the account's
// own seat counts each game once (an open game's empty opponent seat has no account).
func (s *Store) CountActiveQuickGames(ctx context.Context, accountID uuid.UUID) (int, error) {
// The status literals are game.StatusActive / game.StatusOpen, matching the
// games.status CHECK in the baseline migration.
const q = `
SELECT COUNT(*) FROM backend.games g
JOIN backend.game_players gp ON gp.game_id = g.game_id
LEFT JOIN backend.game_invitations gi ON gi.game_id = g.game_id
WHERE gp.account_id = $1 AND g.status IN ('active', 'open') AND gi.game_id IS NULL`
var n int
if err := s.db.QueryRowContext(ctx, q, accountID).Scan(&n); err != nil {
return 0, fmt.Errorf("game: count active quick games: %w", err)
}
return n, nil
}
// HideGame hides a game from the account's own lobby list (idempotent). The caller validates the
// game is finished and the account is a player.
func (s *Store) HideGame(ctx context.Context, accountID, gameID uuid.UUID) error {
@@ -1361,6 +1356,7 @@ func projectGame(g model.Games, seats []model.GamePlayers) (Game, error) {
}
out.MultipleWordsPerTurn = g.MultipleWordsPerTurn
out.VsAI = g.VsAi
out.Kind = gamelimits.Kind(g.GameKind)
if g.EndReason != nil {
out.EndReason = *g.EndReason
}
+11 -16
View File
@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
)
// Status values persisted in the games.status column.
@@ -94,15 +95,6 @@ const DefaultTurnTimeout = 24 * time.Hour
// one of AllowedTurnTimeouts (never offered in the creation UI).
const AIInactivityTimeout = 7 * 24 * time.Hour
// MaxActiveQuickGames is the cap on a player's simultaneous quick games (human
// auto-match and honest-AI), counting both in-progress (StatusActive) and
// still-open, awaiting-opponent (StatusOpen) games. Friend games created by
// invitation are not counted. At the cap the backend refuses to create a new game
// of any kind — quick or by invitation — and the lobby disables "New Game";
// accepting an incoming invitation is always allowed. See
// Store.CountActiveQuickGames.
const MaxActiveQuickGames = 10
// aiPlayerName labels the robot seat in an honest-AI game's GCG export, so a downloaded
// game file shows a clean "AI" rather than the robot's human-like pool name (the in-app
// UI shows 🤖 from the game's vs_ai flag).
@@ -125,6 +117,10 @@ type CreateParams struct {
// robot is seated at once, the move clock is AIInactivityTimeout, and chat/nudge
// and finish-time statistics are suppressed. Set by the lobby's AI-match path.
VsAI bool
// Kind tags the game's origin (games.game_kind) for the active-game limits: vs_ai / random /
// friends. The lobby sets it; a zero (unknown) kind is never gated. The active-game limit itself
// is enforced at the new-game handler (Server.ensureUnderGameLimit), not here.
Kind gamelimits.Kind
}
// Game is the persisted state of a match: the games row joined with its seats.
@@ -151,6 +147,9 @@ type Game struct {
// VsAI is true for an honest-AI game (games.vs_ai): the opponent is a robot the
// player knowingly chose, shown as 🤖, with chat/nudge disabled and no statistics.
VsAI bool
// Kind is the game's origin tag (games.game_kind) for the active-game limits: vs_ai / random /
// friends, or unknown for an untagged game. Read-only projection; set once on creation.
Kind gamelimits.Kind
}
// Seat is one player's standing in a game.
@@ -211,10 +210,9 @@ type MoveResult struct {
BagLen int
}
// HintResult is a revealed hint and the requesting player's remaining hint
// budget (per-seat allowance plus profile wallet) after spending one. WalletBalance is
// the global wallet alone, so the client can keep its live wallet authoritative and
// re-derive the per-game allowance (HintsRemaining - WalletBalance).
// HintResult is a revealed hint with the per-seat allowance remaining (HintsRemaining) and the
// purchasable hint wallet after spending one (WalletBalance, from payments). The client adopts
// WalletBalance into the profile so the badge stays live across games (lib/hints).
type HintResult struct {
Move engine.MoveRecord
HintsRemaining int
@@ -241,9 +239,6 @@ type StateView struct {
Rack []string
BagLen int
HintsRemaining int
// WalletBalance is the player's global hint-wallet balance alone (HintsRemaining folds
// it in with the per-game allowance), so the client keeps the wallet live across games.
WalletBalance int
// HintUnlockLeftSeconds is, for a vs_ai game on the requesting player's turn, the seconds left
// until the idle hint unlocks (the robot's last move plus the idle window, from the server clock);
// 0 for the human's first move, when it is not their turn, or a non-vs_ai game. The vs_ai hint is
+149
View File
@@ -0,0 +1,149 @@
// Package gamelimits holds the per-tier, per-kind active-game caps (a guest funnel) with a hot
// in-memory cache over the single-row backend.config, so a login or a game-create never queries it.
package gamelimits
import (
"context"
"database/sql"
"fmt"
"sync"
"github.com/go-jet/jet/v2/postgres"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Kind is a game's kind, mirroring backend.games.game_kind. 0 (unknown) is an untagged game, never gated.
type Kind int16
const (
KindUnknown Kind = 0
KindVsAI Kind = 1
KindRandom Kind = 2
KindFriends Kind = 3
)
// String returns the kind's label for admin game lists and logs.
func (k Kind) String() string {
switch k {
case KindVsAI:
return "vs_ai"
case KindRandom:
return "random"
case KindFriends:
return "friends"
default:
return "unknown"
}
}
// Unlimited is the limit sentinel meaning no cap.
const Unlimited = -1
// Limits is a tier's active-game caps per kind (-1 = unlimited).
type Limits struct {
VsAI int
Random int
Friends int
}
// Cap returns the limit for kind; the unknown kind is never capped.
func (l Limits) Cap(kind Kind) int {
switch kind {
case KindVsAI:
return l.VsAI
case KindRandom:
return l.Random
case KindFriends:
return l.Friends
default:
return Unlimited
}
}
// Config is the per-tier limit config (the single backend.config row).
type Config struct {
Guest Limits
Durable Limits
}
// Store reads and writes the single-row backend.config.
type Store struct{ db *sql.DB }
// NewStore constructs a Store over db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func (s *Store) load(ctx context.Context) (Config, error) {
var c model.Config
if err := postgres.SELECT(table.Config.AllColumns).FROM(table.Config).LIMIT(1).
QueryContext(ctx, s.db, &c); err != nil {
return Config{}, fmt.Errorf("gamelimits: load config: %w", err)
}
return Config{
Guest: Limits{VsAI: int(c.GuestVsAiLimit), Random: int(c.GuestRandomLimit), Friends: int(c.GuestFriendsLimit)},
Durable: Limits{VsAI: int(c.DurableVsAiLimit), Random: int(c.DurableRandomLimit), Friends: int(c.DurableFriendsLimit)},
}, nil
}
func (s *Store) save(ctx context.Context, c Config) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE backend.config SET guest_vs_ai_limit=$1, guest_random_limit=$2, guest_friends_limit=$3,
durable_vs_ai_limit=$4, durable_random_limit=$5, durable_friends_limit=$6 WHERE only_row`,
c.Guest.VsAI, c.Guest.Random, c.Guest.Friends, c.Durable.VsAI, c.Durable.Random, c.Durable.Friends); err != nil {
return fmt.Errorf("gamelimits: save config: %w", err)
}
return nil
}
// Service fronts the config with an in-memory cache (single-instance, matching the deploy). Load
// once at startup; Update after an admin edit refreshes it in place.
type Service struct {
store *Store
mu sync.RWMutex
cfg Config
}
// NewService constructs a Service over store. Call Load before serving.
func NewService(store *Store) *Service { return &Service{store: store} }
// Load reads the config into the cache. Call once at startup.
func (s *Service) Load(ctx context.Context) error {
c, err := s.store.load(ctx)
if err != nil {
return err
}
s.set(c)
return nil
}
func (s *Service) set(c Config) {
s.mu.Lock()
s.cfg = c
s.mu.Unlock()
}
// Get returns the cached config.
func (s *Service) Get() Config {
s.mu.RLock()
defer s.mu.RUnlock()
return s.cfg
}
// LimitsFor returns the cached limits for the account tier (guest or durable).
func (s *Service) LimitsFor(isGuest bool) Limits {
c := s.Get()
if isGuest {
return c.Guest
}
return c.Durable
}
// Update saves the config and refreshes the cache in place (the admin edit).
func (s *Service) Update(ctx context.Context, c Config) error {
if err := s.store.save(ctx, c); err != nil {
return err
}
s.set(c)
return nil
}
@@ -0,0 +1,44 @@
package gamelimits
import "testing"
// TestLimitsCap checks Cap maps each kind to its field and leaves the unknown kind uncapped, so a
// untagged game (game_kind 0) is never gated.
func TestLimitsCap(t *testing.T) {
l := Limits{VsAI: 1, Random: 2, Friends: 3}
for _, tc := range []struct {
kind Kind
want int
}{
{KindVsAI, 1},
{KindRandom, 2},
{KindFriends, 3},
{KindUnknown, Unlimited},
{Kind(99), Unlimited},
} {
if got := l.Cap(tc.kind); got != tc.want {
t.Errorf("Cap(%d) = %d, want %d", tc.kind, got, tc.want)
}
}
}
// TestServiceLimitsForTier checks LimitsFor selects the guest or durable tier from the cached config.
func TestServiceLimitsForTier(t *testing.T) {
svc := NewService(nil)
svc.set(Config{
Guest: Limits{VsAI: 1, Random: 1, Friends: 0},
Durable: Limits{VsAI: 10, Random: 10, Friends: 10},
})
if got := svc.LimitsFor(true); got != (Limits{VsAI: 1, Random: 1, Friends: 0}) {
t.Errorf("guest limits = %+v, want {1 1 0}", got)
}
if got := svc.LimitsFor(false); got != (Limits{VsAI: 10, Random: 10, Friends: 10}) {
t.Errorf("durable limits = %+v, want {10 10 10}", got)
}
// The unlimited sentinel resolves through the tier too.
svc.set(Config{Durable: Limits{VsAI: Unlimited, Random: Unlimited, Friends: Unlimited}})
if got := svc.LimitsFor(false).Cap(KindVsAI); got != Unlimited {
t.Errorf("durable vs_ai cap = %d, want unlimited (-1)", got)
}
}
@@ -0,0 +1,79 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// benefitFor returns the account's benefit on the given origin from its statement.
func benefitFor(t *testing.T, pay *payments.Service, id uuid.UUID, origin payments.Source) payments.OriginBenefit {
t.Helper()
stmt, err := pay.AccountStatement(context.Background(), id)
if err != nil {
t.Fatalf("statement: %v", err)
}
for _, b := range stmt.Benefits {
if b.Origin == origin {
return b
}
}
return payments.OriginBenefit{}
}
// TestConsoleAdminGrant drives the admin grant: a raw benefit grant, a by-product grant of a reward
// bundle, and a refusal to grant a chips pack; the create is CSRF-guarded.
func TestConsoleAdminGrant(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
h := srv.Handler()
id := provisionAccount(t)
const origin = "http://admin.test"
base := "http://admin.test/_gm/users/" + id.String()
// CSRF: a grant without the origin header is refused.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=1", ""); code != http.StatusForbidden {
t.Fatalf("grant without origin = %d, want 403", code)
}
// Raw grant: 5 hints + 30 no-ads days to the direct origin.
if code, body := consoleDo(h, http.MethodPost, base+"/grant", "origin=direct&hints=5&noads=30", origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
t.Fatalf("raw grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
}
if b := benefitFor(t, pay, id, payments.SourceDirect); b.Hints != 5 || b.AdsPaidUntil.IsZero() {
t.Fatalf("after raw grant: hints=%d adsUntil-zero=%v, want 5 hints + a no-ads term", b.Hints, b.AdsPaidUntil.IsZero())
}
// By-product grant: an archived reward bundle (3 hints) to vk.
reward, err := pay.CreateProduct(ctx, payments.ProductInput{
Title: "reward-3-hints", Atoms: []payments.AtomLine{{Atom: "hints", Quantity: 3}},
}, false)
if err != nil {
t.Fatalf("create reward: %v", err)
}
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=vk&product_id="+reward.String(), origin); code != http.StatusOK || !strings.Contains(body, "Granted") {
t.Fatalf("product grant = %d, has 'Granted' = %v", code, strings.Contains(body, "Granted"))
}
if b := benefitFor(t, pay, id, payments.SourceVK); b.Hints != 3 {
t.Fatalf("after product grant: vk hints=%d, want 3", b.Hints)
}
// A chips pack cannot be granted.
pack, err := pay.CreateProduct(ctx, payments.ProductInput{
Title: "grant-pack", Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 100}},
Prices: []payments.PriceLine{{Method: "direct", Currency: payments.CurrencyRUB, Amount: 14900}},
}, true)
if err != nil {
t.Fatalf("create pack: %v", err)
}
if code, body := consoleDo(h, http.MethodPost, base+"/grant-product", "origin=direct&product_id="+pack.String(), origin); code != http.StatusOK || !strings.Contains(body, "cannot grant chips") {
t.Fatalf("chips grant = %d, has 'cannot grant chips' = %v", code, strings.Contains(body, "cannot grant chips"))
}
}
@@ -0,0 +1,77 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"scrabble/backend/internal/payments"
)
// TestConsoleRefundAndExport drives the manual refund and the ledger CSV export: a funded order is
// refunded in full (chips revoked, a refund row), the refund is idempotent, and the export carries
// the fund + refund rows; the refund POST is CSRF-guarded.
func TestConsoleRefundAndExport(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
h := srv.Handler()
id := provisionAccount(t)
const origin = "http://admin.test"
base := "http://admin.test/_gm/users/" + id.String()
// Fund a pack: 100 chips + a fund ledger row.
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("order: %v", err)
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
// CSRF: a refund without the origin header is refused.
if code, _ := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), ""); code != http.StatusForbidden {
t.Fatalf("refund without origin = %d, want 403", code)
}
// Refund the order in full → 100 chips revoked (none spent).
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "revoked 100 chips") {
t.Fatalf("refund = %d, has 'revoked 100 chips' = %v", code, strings.Contains(body, "revoked 100 chips"))
}
stmt, err := pay.AccountStatement(ctx, id)
if err != nil {
t.Fatalf("statement: %v", err)
}
for _, sg := range stmt.Segments {
if sg.Source == payments.SourceDirect && sg.Chips != 0 {
t.Errorf("after refund: direct chips = %d, want 0", sg.Chips)
}
}
kinds := map[string]int{}
for _, e := range stmt.Ledger {
kinds[e.Kind]++
}
if kinds["fund"] != 1 || kinds["refund"] != 1 {
t.Errorf("ledger kinds = %v, want one fund + one refund", kinds)
}
// A second refund of the same order is idempotent.
if code, body := consoleDo(h, http.MethodPost, base+"/refund", "order_id="+res.OrderID.String(), origin); code != http.StatusOK || !strings.Contains(body, "Already refunded") {
t.Fatalf("second refund = %d, has 'Already refunded' = %v", code, strings.Contains(body, "Already refunded"))
}
// Export the whole ledger as CSV: the header, this account, and its fund + refund rows.
code, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/ledger.csv", "", "")
if code != http.StatusOK {
t.Fatalf("export = %d, want 200", code)
}
for _, want := range []string{"created_at,account_id,kind", id.String(), ",fund,", ",refund,"} {
if !strings.Contains(body, want) {
t.Errorf("ledger CSV missing %q", want)
}
}
}
-71
View File
@@ -4,7 +4,6 @@ package inttest
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -280,76 +279,6 @@ func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
}
}
// TestConsoleGrantHints drives the admin hint-wallet grant end to end: the card shows the form,
// the action is CSRF-guarded, a same-origin grant adds to the wallet, a second grant adds again
// (rather than replacing), the inclusive per-grant cap is accepted, and an out-of-range or
// non-numeric amount is refused without changing the balance.
func TestConsoleGrantHints(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
id := provisionAccount(t)
srv := server.New(":0", server.Deps{
Logger: zap.NewNop(), Accounts: accounts, Games: newGameService(), Registry: testRegistry, DictDir: dictDir(),
})
h := srv.Handler()
base := "http://admin.test/_gm/users/" + id.String()
if code, body := consoleDo(h, http.MethodGet, base, "", ""); code != http.StatusOK || !strings.Contains(body, "Add hints") {
t.Fatalf("user card = %d, has grant form = %v", code, strings.Contains(body, "Add hints"))
}
// The grant POST is CSRF-guarded like every console action.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", ""); code != http.StatusForbidden {
t.Fatalf("grant without origin = %d, want 403", code)
}
// A same-origin grant adds to the wallet.
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=5", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 5") {
t.Fatalf("grant 5 = %d, body has 'now 5' = %v", code, strings.Contains(body, "now 5"))
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 5 {
t.Fatalf("after grant 5: balance=%d err=%v, want 5", acc.HintBalance, err)
}
// A second grant adds again rather than replacing.
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=3", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "now 8") {
t.Fatalf("grant 3 = %d, body has 'now 8' = %v", code, strings.Contains(body, "now 8"))
}
// An out-of-range or non-numeric amount is refused; the balance is left untouched.
for _, bad := range []string{"0", "-1", "101", "x", ""} {
if code, body := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount="+bad, "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Invalid amount") {
t.Fatalf("grant %q = %d, has 'Invalid amount' = %v", bad, code, strings.Contains(body, "Invalid amount"))
}
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 8 {
t.Fatalf("after invalid grants: balance=%d err=%v, want 8", acc.HintBalance, err)
}
// The inclusive per-grant cap (100) is accepted.
if code, _ := consoleDo(h, http.MethodPost, base+"/grant-hints", "amount=100", "http://admin.test"); code != http.StatusOK {
t.Fatalf("grant 100 = %d, want 200", code)
}
if acc, err := accounts.GetByID(ctx, id); err != nil || acc.HintBalance != 108 {
t.Fatalf("after grant 100: balance=%d err=%v, want 108", acc.HintBalance, err)
}
}
// TestGrantHintsStore covers the wallet store method directly: an additive grant raises the
// balance, a non-positive grant is rejected, and an unknown account yields ErrNotFound.
func TestGrantHintsStore(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
id := provisionAccount(t)
if bal, err := accounts.GrantHints(ctx, id, 4); err != nil || bal != 4 {
t.Fatalf("grant 4 = (%d, %v), want (4, nil)", bal, err)
}
if bal, err := accounts.GrantHints(ctx, id, 6); err != nil || bal != 10 {
t.Fatalf("grant 6 = (%d, %v), want (10, nil)", bal, err)
}
if _, err := accounts.GrantHints(ctx, id, 0); err == nil {
t.Error("grant 0 should be rejected (non-positive)")
}
if _, err := accounts.GrantHints(ctx, uuid.New(), 1); !errors.Is(err, account.ErrNotFound) {
t.Errorf("grant unknown account = %v, want ErrNotFound", err)
}
}
// consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
@@ -191,7 +191,7 @@ func TestBannerMessageOwnership(t *testing.T) {
}
}
// profileBanner is the banner block of the profile.get JSON response.
// profileBanner is the banner (and interstitial-ad) block of the profile.get JSON response.
type profileBanner struct {
Banner *struct {
Campaigns []struct {
@@ -203,6 +203,12 @@ type profileBanner struct {
HoldMs int `json:"hold_ms"`
} `json:"timings"`
} `json:"banner"`
Ads *struct {
CooldownGlobalS int `json:"cooldown_global_s"`
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
CooldownHintS int `json:"cooldown_hint_s"`
Suppressed bool `json:"suppressed"`
} `json:"ads"`
}
// TestBannerProfileEligibility checks the profile.get banner block follows
@@ -283,6 +289,81 @@ func TestBannerProfileEligibility(t *testing.T) {
}
}
// TestProfileAdsConfig checks the profile.get interstitial-ad block: the seeded config cooldowns are
// carried through, and Suppressed follows the same no-ads / no_banner gate as the banner (the client
// self-gates VK-only + online on top). The no_banner role is context-independent; the no-ads benefit
// applies only in a trusted context where its segment is present.
func TestProfileAdsConfig(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
accounts := account.NewStore(testDB)
id := provisionAccount(t)
get := func() profileBanner {
t.Helper()
rec := userGet(t, srv, "/api/v1/user/profile", id)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
getTG := func() profileBanner {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/profile", nil)
req.Header.Set("X-User-ID", id.String())
req.Header.Set("X-Platform", "telegram/android")
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("profile = %d, want 200", rec.Code)
}
var p profileBanner
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode profile: %v", err)
}
return p
}
// A free account: the ads block carries the seeded config cooldowns and is not suppressed.
p := get()
if p.Ads == nil {
t.Fatal("free account: no ads block")
}
if p.Ads.CooldownGlobalS != 300 || p.Ads.CooldownVsAiS != 1800 || p.Ads.CooldownHintS != 60 {
t.Fatalf("cooldowns = %d/%d/%d, want 300/1800/60", p.Ads.CooldownGlobalS, p.Ads.CooldownVsAiS, p.Ads.CooldownHintS)
}
if p.Ads.Suppressed {
t.Fatal("free account: interstitials must not be suppressed")
}
// The no_banner role suppresses interstitials too (context-independent).
if err := accounts.GrantRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("grant role: %v", err)
}
if p := get(); p.Ads == nil || !p.Ads.Suppressed {
t.Fatalf("no_banner role: ads=%v, want suppressed", p.Ads)
}
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// An active no-ads benefit suppresses interstitials in a trusted context; an untrusted context
// does not apply it (fail-closed to eligible, as for the banner).
if err := pay.Grant(ctx, id, payments.SourceTelegram, 0, 30, false); err != nil {
t.Fatalf("grant no-ads: %v", err)
}
if p := getTG(); p.Ads == nil || !p.Ads.Suppressed {
t.Fatalf("no-ads benefit (trusted): ads=%v, want suppressed", p.Ads)
}
if p := get(); p.Ads == nil || p.Ads.Suppressed {
t.Fatalf("no-ads benefit (untrusted): ads=%v, want NOT suppressed", p.Ads)
}
}
// TestBannerSurvivesProfileUpdate guards that a profile update (e.g. a language switch) returns the
// banner block too, so the client's profile keeps the banner instead of losing it until reload.
func TestBannerSurvivesProfileUpdate(t *testing.T) {
@@ -440,10 +521,4 @@ func TestBannerUrgentBypassesEligibility(t *testing.T) {
if err := accounts.RevokeRole(ctx, id, account.RoleNoBanner); err != nil {
t.Fatalf("revoke role: %v", err)
}
// A non-empty hint wallet no longer suppresses it either.
if _, err := accounts.GrantHints(ctx, id, 5); err != nil {
t.Fatalf("grant hints: %v", err)
}
assertUrgentOnly("hints", get())
}
@@ -0,0 +1,97 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// productByTitle finds a catalog product by its (unique in the test) title.
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
t.Helper()
all, err := pay.AdminCatalog(context.Background())
if err != nil {
t.Fatalf("admin catalog: %v", err)
}
for _, p := range all {
if p.Title == title {
return p
}
}
t.Fatalf("product %q not found", title)
return payments.AdminProduct{}
}
// TestConsoleCatalogEditor drives the catalog editor end to end: create is CSRF-guarded; a value and
// a pack are created and edited; an invalid active product is refused; archive hides it; a
// never-transacted product deletes; a transacted product is refused deletion (archive only).
func TestConsoleCatalogEditor(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
h := srv.Handler()
const origin = "http://admin.test"
const catalog = "http://admin.test/_gm/catalog"
// CSRF: a create without the origin header is refused.
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=x&hints=1&price_chip=10&active=true", ""); code != http.StatusForbidden {
t.Fatalf("create without origin = %d, want 403", code)
}
// Create a value product: 5 hints for 50 chips, active.
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-hints-5&hints=5&price_chip=50&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Created") {
t.Fatalf("create value = %d, has 'Created' = %v", code, strings.Contains(body, "Created"))
}
val := productByTitle(t, pay, "cat-hints-5")
if !val.Active || len(val.Atoms) != 1 || val.Atoms[0].Atom != "hints" || val.Atoms[0].Quantity != 5 {
t.Fatalf("created value = %+v", val)
}
// An invalid active pack (chips, no money price) is refused.
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
}
if _, err := pay.AdminCatalog(ctx); err != nil {
t.Fatalf("catalog: %v", err)
}
// Edit the value: raise to 8 hints.
base := catalog + "/" + val.ID.String()
if code, _ := consoleDo(h, http.MethodPost, base, "title=cat-hints-8&hints=8&price_chip=50&active=true", origin); code != http.StatusOK {
t.Fatalf("edit = %d", code)
}
if got := productByTitle(t, pay, "cat-hints-8"); len(got.Atoms) != 1 || got.Atoms[0].Quantity != 8 {
t.Fatalf("after edit atoms = %+v, want 8 hints", got.Atoms)
}
// Archive it → hidden from the storefront (the user Catalog filters active).
if code, _ := consoleDo(h, http.MethodPost, base+"/archive", "active=false", origin); code != http.StatusOK {
t.Fatalf("archive = %d", code)
}
if productByTitle(t, pay, "cat-hints-8").Active {
t.Error("product still active after archive")
}
// Delete the never-transacted product.
if code, body := consoleDo(h, http.MethodPost, base+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Deleted") {
t.Fatalf("delete clean = %d, has 'Deleted' = %v", code, strings.Contains(body, "Deleted"))
}
// A transacted product cannot be deleted — only archived.
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=cat-pack&chips=100&price_rub=14900&active=true", origin); code != http.StatusOK {
t.Fatal("create pack failed")
}
pack := productByTitle(t, pay, "cat-pack")
if _, err := pay.CreateOrder(ctx, uuid.New(), payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, pack.ID, "robokassa"); err != nil {
t.Fatalf("create order: %v", err)
}
if code, body := consoleDo(h, http.MethodPost, catalog+"/"+pack.ID.String()+"/delete", "", origin); code != http.StatusOK || !strings.Contains(body, "Cannot delete") {
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
}
}
+186 -103
View File
@@ -5,6 +5,7 @@ package inttest
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -18,59 +19,119 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/server"
"scrabble/backend/internal/social"
)
// The game-limit suite covers the simultaneous quick-game cap (game.MaxActiveQuickGames):
// the counting rule (Store/Service.CountActiveQuickGames) and the HTTP gate that refuses a
// new game once the cap is reached, while accepting an incoming invitation stays allowed.
// The game-limit suite covers the per-tier, per-kind active-game caps (backend.config): the
// game_kind tag persisted per creation path, the tier resolution + counting rule
// (game.Service.AtGameLimit), the HTTP gate + lobby at_game_limit flag, the guest gate on friend
// actions, and the config hot-cache reflecting an admin edit. Accepting an invitation stays exempt.
// TestCountActiveQuickGames checks the count includes active and open quick games (the
// honest-AI ones among them) and excludes finished games, friend games (created by
// invitation) and games the account is not seated in.
func TestCountActiveQuickGames(t *testing.T) {
// newGameLimits builds a gamelimits service over the shared pool and loads the seeded config
// (guest 1/1/0, durable 10/10/10).
func newGameLimits(t *testing.T) *gamelimits.Service {
t.Helper()
gl := gamelimits.NewService(gamelimits.NewStore(testDB))
if err := gl.Load(context.Background()); err != nil {
t.Fatalf("load game limits: %v", err)
}
return gl
}
// restoreDefaultLimits resets the single config row to the migration seed on cleanup, so a test that
// edited it never leaks its values into another (the row is a shared singleton).
func restoreDefaultLimits(t *testing.T, gl *gamelimits.Service) {
t.Helper()
t.Cleanup(func() {
_ = gl.Update(context.Background(), gamelimits.Config{
Guest: gamelimits.Limits{VsAI: 1, Random: 1, Friends: 0},
Durable: gamelimits.Limits{VsAI: 10, Random: 10, Friends: 10},
})
})
}
// gameKind reads a game's persisted game_kind tag.
func gameKind(t *testing.T, gameID uuid.UUID) int16 {
t.Helper()
var k int16
if err := testDB.QueryRowContext(context.Background(),
`SELECT game_kind FROM backend.games WHERE game_id=$1`, gameID).Scan(&k); err != nil {
t.Fatalf("read game_kind: %v", err)
}
return k
}
// mustCreateKind creates an active game seating the accounts, tagged with kind, and returns its id.
func mustCreateKind(t *testing.T, games *game.Service, seats []uuid.UUID, kind gamelimits.Kind) uuid.UUID {
t.Helper()
g, err := games.Create(context.Background(), game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Kind: kind,
})
if err != nil {
t.Fatalf("create game (kind=%d): %v", kind, err)
}
return g.ID
}
// startFriendGameID has inviter invite invitee to a friend game and the invitee accept, returning
// the started game's id.
func startFriendGameID(t *testing.T, inv *lobby.InvitationService, inviter, invitee uuid.UUID) uuid.UUID {
t.Helper()
ctx := context.Background()
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
}
got, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true)
if err != nil {
t.Fatalf("accept invitation: %v", err)
}
if got.GameID == nil {
t.Fatal("accepted invitation has no game id")
}
return *got.GameID
}
// TestGameKindPersisted checks each creation path stamps the right game_kind: random (auto-match)
// =2, vs_ai =1, friend (by invitation) =3.
func TestGameKindPersisted(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
games := newGameService()
human := provisionAccount(t)
opp := provisionAccount(t)
inv := newInvitationService()
human, opp := provisionAccount(t), provisionAccount(t)
if n := mustCount(t, games, human); n != 0 {
t.Fatalf("fresh account count = %d, want 0", n)
rnd, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour}, time.Now().Add(time.Minute), nil)
if err != nil {
t.Fatalf("open random game: %v", err)
}
if k := gameKind(t, rnd.ID); k != int16(gamelimits.KindRandom) {
t.Errorf("random game_kind = %d, want %d", k, gamelimits.KindRandom)
}
// An open (awaiting-opponent) quick game counts.
if _, _, err := games.OpenOrJoin(ctx, human, game.CreateParams{
Variant: engine.VariantEnglish, TurnTimeout: 24 * time.Hour,
}, time.Now().Add(time.Minute), nil); err != nil {
t.Fatalf("open quick game: %v", err)
ai := mustCreateKind(t, games, []uuid.UUID{human, opp}, gamelimits.KindVsAI)
if k := gameKind(t, ai); k != int16(gamelimits.KindVsAI) {
t.Errorf("vs_ai game_kind = %d, want %d", k, gamelimits.KindVsAI)
}
// An active quick game and an honest-AI quick game both count (neither has an invitation row).
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 1)
mustCreateQuick(t, games, []uuid.UUID{human, opp}, true, 2)
// A finished quick game does NOT count.
fin := mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, 3)
if _, err := testDB.ExecContext(ctx, `UPDATE backend.games SET status='finished' WHERE game_id=$1`, fin); err != nil {
t.Fatalf("finish game: %v", err)
}
// A game the human is not seated in does NOT count.
mustCreateQuick(t, games, []uuid.UUID{opp, provisionAccount(t)}, false, 4)
// A friend game (created by invitation) does NOT count, even though it is active.
startFriendGame(t, human)
if n := mustCount(t, games, human); n != 3 {
t.Fatalf("active quick count = %d, want 3 (active + AI + open)", n)
friend := startFriendGameID(t, inv, human, opp)
if k := gameKind(t, friend); k != int16(gamelimits.KindFriends) {
t.Errorf("friend game_kind = %d, want %d", k, gamelimits.KindFriends)
}
}
// TestGameLimitGate drives the cap through the assembled HTTP server: under the cap the lobby
// reports at_game_limit false; at the cap it flips to true and both new-game endpoints (quick
// enqueue and invitation creation) are refused with 409 game_limit_reached, while accepting an
// incoming invitation is still allowed.
func TestGameLimitGate(t *testing.T) {
// TestGuestActiveGameLimitHTTP drives the guest random cap through the assembled server: the first
// auto-match opens a game, the lobby then flags at_game_limit, and a second enqueue is refused 409
// game_limit_reached. It also checks the guest vs_ai and friends caps resolve at the domain level.
func TestGuestActiveGameLimitHTTP(t *testing.T) {
ctx := context.Background()
clearOpenGames(t)
gl := newGameLimits(t)
games := newGameService()
games.SetGameLimits(gl)
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
@@ -78,88 +139,110 @@ func TestGameLimitGate(t *testing.T) {
Games: games,
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
Invitations: newInvitationService(),
GameLimits: gl,
})
human := provisionAccount(t)
guest := provisionGuest(t)
if gamesListAtLimit(t, srv, guest) {
t.Fatal("a fresh guest must be under the random game limit")
}
// The first auto-match opens a random game (guest random cap = 1).
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
t.Fatalf("first enqueue = %d (%s), want 200", rec.Code, rec.Body.String())
}
// At the cap: the lobby flags it and a second enqueue is refused with the stable code.
if !gamesListAtLimit(t, srv, guest) {
t.Fatal("after one random game the guest must be at the limit")
}
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", guest, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("second enqueue = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
// A guest is refused the friends flow outright at the HTTP edge (403 guest_forbidden).
opp := provisionAccount(t)
// Under the cap: the lobby reports the player is not limited.
if gamesListAtLimit(t, srv, human) {
t.Fatal("a fresh account must be under the game limit")
}
// Reach the cap with active quick games seating the human (no invitation row → quick).
for i := 0; i < game.MaxActiveQuickGames; i++ {
mustCreateQuick(t, games, []uuid.UUID{human, opp}, false, int64(i+1))
}
// At the cap: the lobby flags it and both create paths are refused with the stable code.
if !gamesListAtLimit(t, srv, human) {
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
}
// erudit_ru is in the default variant preferences, so the variant gate passes and the
// game-limit gate is what fires here.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
if rec := userPost(t, srv, "/api/v1/user/invitations", guest, invBody); rec.Code != http.StatusForbidden || errorCode(t, rec) != "guest_forbidden" {
t.Fatalf("guest invitation = (%d, %q), want (403, guest_forbidden)", rec.Code, errorCode(t, rec))
}
// Accepting an incoming invitation is never blocked, even at the cap: another player invites
// the capped human, who accepts over HTTP and the game starts (friend games do not count).
inviter := provisionAccount(t)
inv := newInvitationService()
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{human}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
// The guest vs_ai cap is 1: one vs_ai game puts the guest at the vs_ai limit.
mustCreateKind(t, games, []uuid.UUID{guest, opp}, gamelimits.KindVsAI)
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindVsAI); err != nil || !at {
t.Fatalf("guest vs_ai at-limit = (%v, %v), want (true, nil)", at, err)
}
if rec := userPost(t, srv, "/api/v1/user/invitations/"+invitation.ID.String()+"/accept", human, ""); rec.Code != http.StatusOK {
t.Fatalf("accept at limit = %d (%s), want 200 — accept must bypass the cap", rec.Code, rec.Body.String())
// The guest friends cap is 0: a guest is always at the friends limit (the 403 gate blocks first).
if at, err := games.AtGameLimit(ctx, guest, gamelimits.KindFriends); err != nil || !at {
t.Fatalf("guest friends at-limit = (%v, %v), want (true, nil)", at, err)
}
}
// mustCount returns the account's active-quick-game count, failing on error.
func mustCount(t *testing.T, games *game.Service, id uuid.UUID) int {
t.Helper()
n, err := games.CountActiveQuickGames(context.Background(), id)
if err != nil {
t.Fatalf("count active quick games: %v", err)
}
return n
}
// mustCreateQuick creates an active quick game (no invitation) seating the given accounts and
// returns its id; vsAI flags an honest-AI game (the service then applies the 7-day clock).
func mustCreateQuick(t *testing.T, games *game.Service, seats []uuid.UUID, vsAI bool, seed int64) uuid.UUID {
t.Helper()
p := game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: seed}
if vsAI {
p.VsAI = true
p.TurnTimeout = 0 // the service applies the 7-day AI inactivity clock for vs_ai games
}
g, err := games.Create(context.Background(), p)
if err != nil {
t.Fatalf("create quick game (vsAI=%v): %v", vsAI, err)
}
return g.ID
}
// startFriendGame has a fresh inviter invite the given account to a friend game and the invitee
// accept it, so the (active) friend game exists with its game_invitations row.
func startFriendGame(t *testing.T, invitee uuid.UUID) {
t.Helper()
// TestDurableTierHigherLimit checks a durable account resolves the durable tier, not the guest one:
// one random game leaves it well under the durable cap (10).
func TestDurableTierHigherLimit(t *testing.T) {
ctx := context.Background()
gl := newGameLimits(t)
games := newGameService()
games.SetGameLimits(gl)
durable, opp := provisionAccount(t), provisionAccount(t)
mustCreateKind(t, games, []uuid.UUID{durable, opp}, gamelimits.KindRandom)
if at, err := games.AtGameLimit(ctx, durable, gamelimits.KindRandom); err != nil || at {
t.Fatalf("durable random at-limit after one game = (%v, %v), want (false, nil)", at, err)
}
}
// TestGuestForbiddenFriendActions checks a guest is refused every friend action server-side (the UI
// hides them; this is the source of truth): creating an invitation, sending a friend request, and
// redeeming a friend code.
func TestGuestForbiddenFriendActions(t *testing.T) {
ctx := context.Background()
guest := provisionGuest(t)
other := provisionAccount(t)
inv := newInvitationService()
if _, err := inv.CreateInvitation(ctx, guest, []uuid.UUID{other}, englishInvite()); !errors.Is(err, lobby.ErrGuestForbidden) {
t.Fatalf("guest CreateInvitation err = %v, want ErrGuestForbidden", err)
}
soc := newSocialService()
if err := soc.SendFriendRequest(ctx, guest, other); !errors.Is(err, social.ErrGuestForbidden) {
t.Fatalf("guest SendFriendRequest err = %v, want ErrGuestForbidden", err)
}
if _, err := soc.RedeemFriendCode(ctx, guest, "000000"); !errors.Is(err, social.ErrGuestForbidden) {
t.Fatalf("guest RedeemFriendCode err = %v, want ErrGuestForbidden", err)
}
}
// TestDurableFriendsCapAndConfigCache lowers the durable friends cap to 1 through the service (the
// admin-edit path), checks a durable inviter is refused a second friend game with 409, and that
// accepting an incoming invitation is still exempt from the cap.
func TestDurableFriendsCapAndConfigCache(t *testing.T) {
ctx := context.Background()
gl := newGameLimits(t)
restoreDefaultLimits(t, gl)
cfg := gl.Get()
cfg.Durable.Friends = 1
if err := gl.Update(ctx, cfg); err != nil {
t.Fatalf("update durable friends cap: %v", err)
}
games := newGameService()
games.SetGameLimits(gl)
inv := lobby.NewInvitationService(lobby.NewStore(testDB), games, account.NewStore(testDB), newSocialService())
inviter := provisionAccount(t)
invitation, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{invitee}, englishInvite())
if err != nil {
t.Fatalf("create invitation: %v", err)
d2, d3 := provisionAccount(t), provisionAccount(t)
// The inviter's first friend game reaches the (lowered) cap of 1.
startFriendGameID(t, inv, inviter, d2)
if at, err := games.AtGameLimit(ctx, inviter, gamelimits.KindFriends); err != nil || !at {
t.Fatalf("inviter friends at-limit = (%v, %v), want (true, nil)", at, err)
}
if _, err := inv.RespondInvitation(ctx, invitation.ID, invitee, true); err != nil {
t.Fatalf("accept invitation: %v", err)
// A second invitation is refused: the cache reflects the edit.
if _, err := inv.CreateInvitation(ctx, inviter, []uuid.UUID{d3}, englishInvite()); !errors.Is(err, game.ErrGameLimitReached) {
t.Fatalf("second invitation err = %v, want ErrGameLimitReached", err)
}
// Accept stays exempt: someone else invites the capped inviter, who accepts and the game starts.
startFriendGameID(t, inv, d3, inviter)
}
// gamesListAtLimit fetches /api/v1/user/games and returns its at_game_limit flag.
+7 -6
View File
@@ -424,13 +424,13 @@ func TestHintPolicy(t *testing.T) {
t.Fatalf("first hint: %v", err)
}
// The allowance is spent before the wallet: with an empty wallet, the state now reports no
// hints left and a zero wallet, so the per-game allowance (HintsRemaining-WalletBalance) is 0.
// per-game allowance left (HintsRemaining is the allowance alone; the wallet lives on the profile).
st, err := svc.GameState(ctx, g.ID, seats[0])
if err != nil {
t.Fatalf("state: %v", err)
}
if st.HintsRemaining != 0 || st.WalletBalance != 0 {
t.Errorf("after allowance hint: hints=%d wallet=%d, want 0/0", st.HintsRemaining, st.WalletBalance)
if st.HintsRemaining != 0 {
t.Errorf("after allowance hint: hints=%d, want 0", st.HintsRemaining)
}
if _, err := svc.Hint(ctx, g.ID, seats[0]); !errors.Is(err, game.ErrNoHintsLeft) {
t.Fatalf("second hint = %v, want ErrNoHintsLeft", err)
@@ -444,9 +444,10 @@ func TestHintPolicy(t *testing.T) {
if err != nil {
t.Fatalf("wallet hint: %v", err)
}
// The allowance stays exhausted; the wallet dropped 2->1, and WalletBalance carries it alone.
if res.HintsRemaining != 1 || res.WalletBalance != 1 {
t.Errorf("wallet hint: hints=%d wallet=%d, want 1/1", res.HintsRemaining, res.WalletBalance)
// The allowance stays exhausted (HintsRemaining is the allowance alone, so 0); the wallet dropped
// 2->1 and WalletBalance carries it (the client adopts it into the profile).
if res.HintsRemaining != 0 || res.WalletBalance != 1 {
t.Errorf("wallet hint: hints=%d wallet=%d, want 0/1", res.HintsRemaining, res.WalletBalance)
}
// game_players.hints_used counts BOTH hints (1 allowance + 1 wallet) — the per-game total
// that feeds the player's lifetime hint statistics, not just the allowance.
@@ -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)
}
}
@@ -4,6 +4,7 @@ package inttest
import (
"context"
"database/sql"
"errors"
"testing"
@@ -23,6 +24,21 @@ func orderStatus(t *testing.T, orderID uuid.UUID) string {
return status
}
// readRisk reads an account's payment-risk row (abuse flag + accumulated loss), or (false, 0) when
// none exists.
func readRisk(t *testing.T, acc uuid.UUID) (abuse bool, loss int64) {
t.Helper()
err := testDB.QueryRowContext(context.Background(),
`SELECT abuse, loss_chips FROM payments.account_risk WHERE account_id=$1`, acc).Scan(&abuse, &loss)
if errors.Is(err, sql.ErrNoRows) {
return false, 0
}
if err != nil {
t.Fatalf("read risk: %v", err)
}
return abuse, loss
}
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
// credits nothing more — the ledger idempotency index holds.
@@ -224,6 +240,100 @@ func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
}
}
// setRewardConfig sets the rewarded payout and caps on the shared config row, restoring the defaults
// after the test (the row is a singleton shared across the sequential suite).
func setRewardConfig(t *testing.T, payout, dailyCap, hourlyCap int) {
t.Helper()
if _, err := testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
payout, dailyCap, hourlyCap); err != nil {
t.Fatalf("set reward config: %v", err)
}
t.Cleanup(func() {
_, _ = testDB.ExecContext(context.Background(),
`UPDATE payments.config SET rewarded_payout_chips=0, reward_daily_cap=50, reward_hourly_cap=10`)
})
}
// TestPaymentsRewardCredit exercises the rewarded-video credit: a watched view credits the VK segment
// the configured payout, a retried view (same nonce) credits once, and the hourly cap blocks the
// third distinct view.
func TestPaymentsRewardCredit(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 5, 5, 2) // 5 chips/view, daily 5, hourly 2
vk := payments.NewContext("vk", "web")
present := []payments.Source{payments.SourceVK}
out, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("credit: %v", err)
}
if out.Chips != 5 || out.Capped {
t.Fatalf("reward outcome = %+v, want 5 chips, not capped", out)
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance = %d, want 5", got)
}
// A retried view (same nonce) credits nothing more.
out2, err := svc.CreditReward(ctx, acc, vk, present, "n1")
if err != nil {
t.Fatalf("retry: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried view not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 5 {
t.Errorf("vk balance after retry = %d, want 5 (credited once)", got)
}
// A second distinct view credits again (2 total).
if _, err := svc.CreditReward(ctx, acc, vk, present, "n2"); err != nil {
t.Fatalf("credit 2: %v", err)
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance = %d, want 10", got)
}
// The third distinct view hits the hourly cap (2) — capped, no credit.
out3, err := svc.CreditReward(ctx, acc, vk, present, "n3")
if err != nil {
t.Fatalf("credit 3: %v", err)
}
if !out3.Capped {
t.Error("third view not capped (hourly cap 2)")
}
if got := readBalance(t, acc, "vk"); got != 10 {
t.Errorf("vk balance after cap = %d, want 10 (capped view credited nothing)", got)
}
}
// TestPaymentsRewardDisabledAndContext verifies rewarded is inert when unconfigured (0 payout) and
// refused outside a VK context (rewarded is VK-only, D28).
func TestPaymentsRewardDisabledAndContext(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
setRewardConfig(t, 0, 50, 10) // payout 0 = disabled
vk := payments.NewContext("vk", "web")
out, err := svc.CreditReward(ctx, acc, vk, []payments.Source{payments.SourceVK}, "d1")
if err != nil {
t.Fatalf("credit (disabled): %v", err)
}
if out.Chips != 0 || out.Capped || out.AlreadyCredited {
t.Fatalf("disabled reward outcome = %+v, want 0 chips, not capped", out)
}
// A direct (non-VK) context is refused — rewarded is VK-only.
direct := payments.NewContext("direct", "web")
if _, err := svc.CreditReward(ctx, acc, direct, []payments.Source{payments.SourceDirect}, "d2"); !errors.Is(err, payments.ErrUntrusted) {
t.Fatalf("direct-context reward = %v, want ErrUntrusted", err)
}
}
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
// refused and credits nothing (§9: verify the amount after matching by order id).
func TestPaymentsFundAmountMismatch(t *testing.T) {
@@ -326,3 +436,128 @@ func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
}
}
// fundedOrder creates and funds an order for chips in the direct rail, returning its id and account.
func fundedOrder(t *testing.T, svc *payments.Service, chips int, priceMinor int64) (uuid.UUID, uuid.UUID) {
t.Helper()
ctx := context.Background()
acc := uuid.New()
prod := seedPackProduct(t, chips, methodPrice{method: "direct", currency: "RUB", amount: priceMinor})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(priceMinor, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
return res.OrderID, acc
}
// TestPaymentsRefundFull reverses a fully-unspent order: all chips are clawed back, no loss/abuse.
func TestPaymentsRefundFull(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-1", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.AlreadyRefunded || out.Revoked != 100 || out.Loss != 0 || out.Source != payments.SourceDirect {
t.Fatalf("refund outcome = %+v, want 100 revoked, 0 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1", ledgerRows(t, acc, "refund"))
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — nothing was spent", abuse, loss)
}
// The order stays 'paid' — the refund lives in the ledger, not in the order status.
if orderStatus(t, orderID) != "paid" {
t.Errorf("order status = %s, want paid (refund is ledger-only)", orderStatus(t, orderID))
}
}
// TestPaymentsRefundAfterSpend reverses an order whose chips were partly spent: the reversal floors
// at 0 (never negative), and the unrecoverable remainder is recorded as a loss + abuse flag.
func TestPaymentsRefundAfterSpend(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
// Simulate 70 chips already spent, leaving 30 in the funded segment.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 30 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("simulate spend: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-2", refunded)
if err != nil {
t.Fatalf("refund: %v", err)
}
if out.Revoked != 30 || out.Loss != 70 {
t.Fatalf("refund outcome = %+v, want 30 revoked / 70 loss", out)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance after refund = %d, want 0 (floored, never negative)", got)
}
if abuse, loss := readRisk(t, acc); !abuse || loss != 70 {
t.Errorf("risk = (%v, %d), want (true, 70)", abuse, loss)
}
}
// TestPaymentsRefundIdempotent verifies a replayed refund (same provider refund id) reverses nothing
// more — the ledger idempotency index holds.
func TestPaymentsRefundIdempotent(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
orderID, acc := fundedOrder(t, svc, 100, 14900)
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded); err != nil {
t.Fatalf("first refund: %v", err)
}
// Re-credit the segment to prove the duplicate does not revoke again.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.balances SET chips = 100 WHERE account_id = $1 AND source = 'direct'`, acc); err != nil {
t.Fatalf("re-credit: %v", err)
}
out2, err := svc.Refund(ctx, orderID, "robokassa", "rk-refund-dup", refunded)
if err != nil {
t.Fatalf("duplicate refund: %v", err)
}
if !out2.AlreadyRefunded {
t.Error("duplicate refund not flagged AlreadyRefunded")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate refund = %d, want 100 (not revoked twice)", got)
}
if ledgerRows(t, acc, "refund") != 1 {
t.Errorf("refund ledger rows = %d, want 1 (duplicate wrote none)", ledgerRows(t, acc, "refund"))
}
}
// TestPaymentsRefundUnpaidOrder refuses to refund an order that was never funded.
func TestPaymentsRefundUnpaidOrder(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
refunded, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Refund(ctx, res.OrderID, "robokassa", "rk-refund-x", refunded); !errors.Is(err, payments.ErrOrderNotPaid) {
t.Fatalf("refund of a pending order = %v, want ErrOrderNotPaid", err)
}
if abuse, loss := readRisk(t, acc); abuse || loss != 0 {
t.Errorf("risk = (%v, %d), want (false, 0) — no reversal on an unpaid order", abuse, loss)
}
}
@@ -0,0 +1,101 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"strings"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// TestAccountStatement checks the admin financial statement: a funded pack shows as a chip segment
// + a fund ledger row, an admin grant as a benefit + an admin_grant row, the ledger is newest-first,
// and a clean account carries no refund risk.
func TestAccountStatement(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
// A funded pack: 100 chips to the direct segment + a fund ledger row.
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
// An admin grant: 5 hints to the direct origin + an admin_grant ledger row.
if err := svc.Grant(ctx, acc, payments.SourceDirect, 5, 0, false); err != nil {
t.Fatalf("grant: %v", err)
}
stmt, err := svc.AccountStatement(ctx, acc)
if err != nil {
t.Fatalf("statement: %v", err)
}
if len(stmt.Segments) != 1 || stmt.Segments[0].Source != payments.SourceDirect || stmt.Segments[0].Chips != 100 {
t.Fatalf("segments = %+v, want 100 chips on direct", stmt.Segments)
}
if len(stmt.Benefits) != 1 || stmt.Benefits[0].Origin != payments.SourceDirect || stmt.Benefits[0].Hints != 5 {
t.Fatalf("benefits = %+v, want 5 hints on direct", stmt.Benefits)
}
if len(stmt.Ledger) != 2 {
t.Fatalf("ledger rows = %d, want 2 (fund + admin_grant)", len(stmt.Ledger))
}
// Newest-first ordering (the grant is the later write).
if stmt.Ledger[0].CreatedAt.Before(stmt.Ledger[1].CreatedAt) {
t.Error("ledger is not newest-first")
}
kinds := map[string]int{}
for _, e := range stmt.Ledger {
kinds[e.Kind]++
if e.Kind == "fund" && e.ChipsDelta != 100 {
t.Errorf("fund chips delta = %d, want 100", e.ChipsDelta)
}
}
if kinds["fund"] != 1 || kinds["admin_grant"] != 1 {
t.Fatalf("ledger kinds = %v, want one fund + one admin_grant", kinds)
}
if stmt.Risk.Present {
t.Errorf("risk = %+v, want none for a clean account", stmt.Risk)
}
}
// TestConsoleFinancePanel checks the user card renders the finance panel: a funded pack + an admin
// grant surface as the segment balance, the benefit and the ledger rows.
func TestConsoleFinancePanel(t *testing.T) {
ctx := context.Background()
srv, _, pay := bannerServer(t)
id := provisionAccount(t)
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := pay.CreateOrder(ctx, id, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
if _, err := pay.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid); err != nil {
t.Fatalf("fund: %v", err)
}
if err := pay.Grant(ctx, id, payments.SourceDirect, 5, 0, false); err != nil {
t.Fatalf("grant: %v", err)
}
code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/users/"+id.String(), "", "")
if code != http.StatusOK {
t.Fatalf("user card = %d, want 200", code)
}
for _, want := range []string{"Finance", "Chips (direct)", "Benefits (direct)", "5 hints", "admin_grant", "fund"} {
if !strings.Contains(body, want) {
t.Errorf("finance panel missing %q", want)
}
}
}
+16
View File
@@ -15,6 +15,7 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
@@ -218,6 +219,20 @@ func (svc *InvitationService) CreateInvitation(ctx context.Context, inviterID uu
if !slices.Contains(game.AllowedTurnTimeouts, settings.TurnTimeout) {
return Invitation{}, fmt.Errorf("%w: turn timeout %s not allowed", ErrInvalidInvitation, settings.TurnTimeout)
}
// A guest cannot invite: friend games are a durable-account feature. The UI hides the flow;
// this is the server source of truth.
if acc, err := svc.accounts.GetByID(ctx, inviterID); err != nil {
return Invitation{}, err
} else if acc.IsGuest {
return Invitation{}, ErrGuestForbidden
}
// A durable inviter is still capped: refuse a new friend game once the per-tier friends limit is
// reached. The guest branch above short-circuits before here, so this is the durable cap.
if atLimit, err := svc.games.AtGameLimit(ctx, inviterID, gamelimits.KindFriends); err != nil {
return Invitation{}, err
} else if atLimit {
return Invitation{}, game.ErrGameLimitReached
}
seen := map[uuid.UUID]bool{inviterID: true}
// suppressed collects invitees who have blocked the inviter: the invitation is still
// created and persisted for them, but they are never notified and never see it (their
@@ -336,6 +351,7 @@ func (svc *InvitationService) startGame(ctx context.Context, invitationID uuid.U
HintsPerPlayer: inv.Settings.HintsPerPlayer,
DropoutTiles: inv.Settings.DropoutTiles,
MultipleWordsPerTurn: inv.Settings.MultipleWordsPerTurn,
Kind: gamelimits.KindFriends,
})
if err != nil {
return err
+9 -1
View File
@@ -15,17 +15,22 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify"
)
// GameCreator is the slice of the game domain the lobby needs: starting a seated
// game and reading a player's initial view of it. game.Service satisfies it.
// game, reading a player's initial view of it, and testing a caller's active-game
// cap. game.Service satisfies it.
type GameCreator interface {
Create(ctx context.Context, params game.CreateParams) (game.Game, error)
// InitialState returns a seated player's full initial view of a started game, used
// to enrich the game_started event so the client renders the new game without a
// follow-up fetch.
InitialState(ctx context.Context, gameID, accountID uuid.UUID) (notify.PlayerState, error)
// AtGameLimit reports whether accountID has reached its tier's active-game cap for kind. The
// invitation path uses it to enforce the friends limit before opening one.
AtGameLimit(ctx context.Context, accountID uuid.UUID, kind gamelimits.Kind) (bool, error)
}
// RobotProvider supplies a robot account to substitute for a missing human in
@@ -62,6 +67,9 @@ var (
// ErrInvalidInvitation is returned for a malformed invitation (bad player
// count, duplicate or self invitee, or unacceptable settings).
ErrInvalidInvitation = errors.New("lobby: invalid invitation")
// ErrGuestForbidden is returned when a guest attempts a durable-only action (invite a
// friend); the friend flow is gated to durable accounts.
ErrGuestForbidden = errors.New("lobby: guests cannot invite")
// ErrInvitationBlocked is returned when a block stands between the inviter and
// an invitee.
ErrInvitationBlocked = errors.New("lobby: invitation blocked between accounts")
+2
View File
@@ -10,6 +10,7 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/notify"
)
@@ -141,6 +142,7 @@ func (m *Matchmaker) StartVsAI(ctx context.Context, accountID uuid.UUID, variant
if rand.IntN(2) == 1 {
params.Seats = []uuid.UUID{robotID, accountID}
}
params.Kind = gamelimits.KindVsAI
g, err := m.games.Create(ctx, params)
if err != nil {
return EnqueueResult{}, err
+1 -1
View File
@@ -37,6 +37,7 @@ func toWireGame(g GameSummary) wire.GameView {
TurnTimeoutSecs: g.TurnTimeoutSecs,
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
Kind: g.Kind,
MoveCount: g.MoveCount,
EndReason: g.EndReason,
Seats: seats,
@@ -83,7 +84,6 @@ func buildStateView(b *flatbuffers.Builder, s PlayerState) flatbuffers.UOffsetT
Rack: s.Rack,
BagLen: s.BagLen,
HintsRemaining: s.HintsRemaining,
WalletBalance: s.WalletBalance,
Alphabet: alphabet,
})
}
+2 -2
View File
@@ -115,7 +115,7 @@ func TestGameOverPayloadRoundTrips(t *testing.T) {
func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
uid, gid := uuid.New(), uuid.New()
move := engine.MoveRecord{Player: 1, Action: engine.ActionPlay, Words: []string{"STOOL"}, Score: 24, Total: 130}
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
summary := notify.GameSummary{ID: gid.String(), MoveCount: 9, ToMove: 0, Kind: 2, Seats: []notify.SeatStanding{{Seat: 1, Score: 130}}}
in := notify.OpponentMoved(uid, gid, move, summary, 42)
if in.Kind != notify.KindOpponentMoved {
t.Fatalf("kind = %q", in.Kind)
@@ -132,7 +132,7 @@ func TestOpponentMovedPayloadRoundTrips(t *testing.T) {
if m == nil || m.Player() != 1 || string(m.Action()) != "play" || m.Total() != 130 {
t.Fatalf("move wrong: %+v", m)
}
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 {
if g := ev.Game(nil); g == nil || g.MoveCount() != 9 || g.ToMove() != 0 || g.Kind() != 2 {
t.Fatalf("game summary wrong: %+v", ev.Game(nil))
}
}
+3 -1
View File
@@ -35,6 +35,9 @@ type GameSummary struct {
EndReason string
Seats []SeatStanding
LastActivityUnix int64
// Kind is the game's origin for the active-game limits (0 unknown, 1 vs_ai, 2 random, 3 friends);
// it rides live events so a lobby patch keeps the per-kind count correct.
Kind int
}
// AlphabetLetter is one variant alphabet entry (a display-only row) embedded in an
@@ -56,7 +59,6 @@ type PlayerState struct {
Rack []int
BagLen int
HintsRemaining int
WalletBalance int
Alphabet []AlphabetLetter
}
+269
View File
@@ -0,0 +1,269 @@
package payments
import (
"cmp"
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"github.com/google/uuid"
)
// AdminProduct is one catalog product for the admin editor: its full composition, every price, the
// archived flag (Active), and whether it has ever been transacted — which forbids a hard delete
// (orders / ledger rows reference it, and the ledger is append-only).
type AdminProduct struct {
ID uuid.UUID
Title string
Active bool
Atoms []AtomLine
Prices []PriceLine
Transacted bool
}
// AtomLine is one atom of a product: the atom type and its positive quantity.
type AtomLine struct {
Atom string
Quantity int
}
// PriceLine is one price of a product: the payment method ("" for a value's CHIP price, which is
// stored with a NULL method), the currency, and the amount in that currency's minor units.
type PriceLine struct {
Method string
Currency Currency
Amount int64
}
// ProductInput is the editable content of a product: its title, atom composition and prices.
type ProductInput struct {
Title string
Atoms []AtomLine
Prices []PriceLine
}
// knownAtoms is the fixed atom set, mirroring the catalog_atom seed / CHECK.
var knownAtoms = map[string]bool{atomChips: true, "hints": true, "noads_days": true, "tournament": true}
// validCurrency reports whether c is one of the four catalog currencies.
func validCurrency(c Currency) bool {
switch c {
case CurrencyRUB, CurrencyVote, CurrencyStar, CurrencyChip:
return true
}
return false
}
// validateProduct checks a product's composition and prices. When sellable is true (the product is
// or is becoming active) it also enforces the storefront shape: a pack (the chips atom ⇒ a money
// price per method and no CHIP price, chips-only) or a value (no chips ⇒ a single CHIP price and no
// money price). The tournament atom is never sellable (its economy is the tournament stage), so an
// active product may not carry it; an archived draft may (a template for later) and skips the shape
// check.
func validateProduct(in ProductInput, sellable bool) error {
if strings.TrimSpace(in.Title) == "" {
return errors.New("product title is required")
}
if len(in.Atoms) == 0 {
return errors.New("product needs at least one atom")
}
seen := map[string]bool{}
hasChips, hasTournament, hasBenefit := false, false, false
for _, a := range in.Atoms {
if !knownAtoms[a.Atom] {
return fmt.Errorf("unknown atom %q", a.Atom)
}
if seen[a.Atom] {
return fmt.Errorf("duplicate atom %q", a.Atom)
}
seen[a.Atom] = true
if a.Quantity <= 0 {
return fmt.Errorf("atom %q quantity must be positive", a.Atom)
}
switch a.Atom {
case atomChips:
hasChips = true
case "tournament":
hasTournament = true
default:
hasBenefit = true
}
}
priceSeen := map[string]bool{}
hasChipPrice, hasMoneyPrice := false, false
for _, p := range in.Prices {
if p.Method != "" && p.Method != string(SourceVK) && p.Method != string(SourceTelegram) && p.Method != string(SourceDirect) {
return fmt.Errorf("invalid price method %q", p.Method)
}
if !validCurrency(p.Currency) {
return fmt.Errorf("invalid currency %q", p.Currency)
}
if p.Amount < 0 {
return errors.New("price amount must be non-negative")
}
if p.Currency == CurrencyChip && p.Method != "" {
return errors.New("a CHIP price must have no method")
}
if p.Currency != CurrencyChip && p.Method == "" {
return fmt.Errorf("a %s price needs a payment method", p.Currency)
}
key := p.Method + "|" + string(p.Currency)
if priceSeen[key] {
return fmt.Errorf("duplicate price (%s, %s)", p.Method, p.Currency)
}
priceSeen[key] = true
if p.Currency == CurrencyChip {
hasChipPrice = true
} else {
hasMoneyPrice = true
}
}
if !sellable {
return nil // an archived draft may be incomplete
}
if hasTournament {
return errors.New("a product carrying the tournament atom cannot be sold yet")
}
if hasChips {
if hasBenefit {
return errors.New("a chip pack must contain only the chips atom")
}
if !hasMoneyPrice || hasChipPrice {
return errors.New("a chip pack needs a money price per method and no CHIP price")
}
} else {
if !hasChipPrice || hasMoneyPrice {
return errors.New("a value needs a single CHIP price and no money price")
}
}
return nil
}
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
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
}
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
// satisfy the sellable shape.
func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInput) (err error) {
active, err := s.store.productActive(ctx, id)
if err != nil {
return err
}
if err := validateProduct(in, active); err != nil {
return err
}
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
// sellable shape, so a draft or a tournament product cannot be put on sale.
func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active bool) error {
if active {
in, err := s.store.productInput(ctx, id)
if err != nil {
return err
}
if err := validateProduct(in, true); err != nil {
return err
}
}
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 {
if err := s.store.deleteProduct(ctx, id); err != nil {
return err
}
s.markOfferStale()
return nil
}
@@ -0,0 +1,47 @@
package payments
import "testing"
func TestValidateProduct(t *testing.T) {
pack := ProductInput{
Title: "100 chips",
Atoms: []AtomLine{{Atom: "chips", Quantity: 100}},
Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 14900}},
}
value := ProductInput{
Title: "5 hints",
Atoms: []AtomLine{{Atom: "hints", Quantity: 5}},
Prices: []PriceLine{{Method: "", Currency: CurrencyChip, Amount: 50}},
}
tests := []struct {
name string
in ProductInput
sellable bool
wantErr bool
}{
{"valid pack", pack, true, false},
{"valid value", value, true, false},
{"pack with a benefit atom", ProductInput{Title: "x", Atoms: []AtomLine{{"chips", 100}, {"hints", 5}}, Prices: pack.Prices}, true, true},
{"pack without money price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: value.Prices}, true, true},
{"value without chip price", ProductInput{Title: "x", Atoms: value.Atoms, Prices: pack.Prices}, true, true},
{"tournament sellable is refused", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}, Prices: value.Prices}, true, true},
{"tournament draft is allowed", ProductInput{Title: "cup", Atoms: []AtomLine{{"tournament", 1}}}, false, false},
{"unknown atom", ProductInput{Title: "x", Atoms: []AtomLine{{"gold", 1}}}, false, true},
{"duplicate atom", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 1}, {"hints", 2}}}, false, true},
{"non-positive quantity", ProductInput{Title: "x", Atoms: []AtomLine{{"hints", 0}}}, false, true},
{"chip price with a method", ProductInput{Title: "x", Atoms: value.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyChip, Amount: 50}}}, true, true},
{"money price without a method", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "", Currency: CurrencyRUB, Amount: 149}}}, true, true},
{"duplicate price", ProductInput{Title: "x", Atoms: pack.Atoms, Prices: []PriceLine{{Method: "direct", Currency: CurrencyRUB, Amount: 1}, {Method: "direct", Currency: CurrencyRUB, Amount: 2}}}, true, true},
{"empty title", ProductInput{Title: " ", Atoms: value.Atoms, Prices: value.Prices}, true, true},
{"no atoms", ProductInput{Title: "x", Prices: value.Prices}, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateProduct(tt.in, tt.sellable)
if (err != nil) != tt.wantErr {
t.Fatalf("validateProduct = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
+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
}
+13 -10
View File
@@ -89,8 +89,10 @@ func NewContext(kind, subtype string) Context {
// every spend/purchase and the application of any foreign origin.
func (c Context) Trusted() bool { return c.Kind.Valid() }
// vkFrozen reports whether this is the VK-iOS spend freeze: VK context on the trusted iOS
// subtype. A previously bought benefit still applies there, but no spend or purchase is possible.
// vkFrozen reports whether this is the VK-iOS purchase freeze: VK context on the trusted iOS
// subtype. Apple's ToS forbids only BUYING in-app values there, so a purchase (money -> chips) is
// refused; earning chips (rewarded ads) and SPENDING chips already in the VK wallet — earned or
// bought on the same account elsewhere (e.g. VK Android) — are legal and stay allowed.
func (c Context) vkFrozen() bool { return c.Kind == SourceVK && c.Subtype == SubtypeIOS }
// spendPriority is the fixed draw order when several segments are spendable in one context
@@ -103,11 +105,12 @@ func has(present []Source, s Source) bool {
}
// spendableSources returns the chip segments that may be SPENT in the context, in draw-priority
// order, restricted to the sources the account actually has (present). It is empty when the
// platform is untrusted (fail-closed) or VK-iOS (frozen): inside VK/TG only the same-named
// segment is spendable; on web/native all attached segments are, drained direct→vk→tg.
// order, restricted to the sources the account actually has (present). It is empty only when the
// platform is untrusted (fail-closed); VK-iOS is NOT excluded — the freeze is purchase-only, so
// spending VK-wallet chips there is allowed. Inside VK/TG only the same-named segment is spendable;
// on web/native all attached segments are, drained direct→vk→tg.
func spendableSources(c Context, present []Source) []Source {
if !c.Trusted() || c.vkFrozen() {
if !c.Trusted() {
return nil
}
switch c.Kind {
@@ -128,10 +131,10 @@ func spendableSources(c Context, present []Source) []Source {
}
// applicableOrigins returns the benefit origins that APPLY in the context, in draw-priority
// order, restricted to present sources. It differs from spendableSources in one way: VK-iOS is
// NOT excluded — a benefit bought earlier still applies while spending is frozen. Inside VK/TG
// only the same-named origin applies (a foreign, e.g. direct, origin never activates inside a
// store — the compliance wall); on web/native direct+vk+tg all apply, drained direct→vk→tg.
// order, restricted to present sources. It mirrors spendableSources (both gate only on a trusted
// platform now that the VK-iOS freeze is purchase-only). Inside VK/TG only the same-named origin
// applies (a foreign, e.g. direct, origin never activates inside a store — the compliance wall);
// on web/native direct+vk+tg all apply, drained direct→vk→tg.
func applicableOrigins(c Context, present []Source) []Source {
if !c.Trusted() {
return nil
+4 -3
View File
@@ -16,7 +16,8 @@ func TestSpendableSources(t *testing.T) {
want []Source
}{
{"vk android, vk present", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"vk ios frozen", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, nil},
// VK-iOS spends its own vk segment: the freeze is purchase-only, spending is allowed.
{"vk ios spends vk", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android, vk absent", Context{Kind: SourceVK, Subtype: "android"}, []Source{SourceDirect}, nil},
{"telegram", Context{Kind: SourceTelegram, Subtype: "web"}, allPresent, []Source{SourceTelegram}},
{"telegram, tg absent", Context{Kind: SourceTelegram}, []Source{SourceVK}, nil},
@@ -41,8 +42,8 @@ func TestApplicableOrigins(t *testing.T) {
present []Source
want []Source
}{
// A benefit still APPLIES on VK-iOS while spending is frozen.
{"vk ios still applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
// A vk-origin benefit applies on VK-iOS (spending is allowed there — the freeze is purchase-only).
{"vk ios applies", Context{Kind: SourceVK, Subtype: SubtypeIOS}, allPresent, []Source{SourceVK}},
{"vk android", Context{Kind: SourceVK, Subtype: "android"}, allPresent, []Source{SourceVK}},
{"telegram", Context{Kind: SourceTelegram}, allPresent, []Source{SourceTelegram}},
{"direct all, priority", Context{Kind: SourceDirect}, allPresent, []Source{SourceDirect, SourceVK, SourceTelegram}},
+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)
}
}
+41
View File
@@ -0,0 +1,41 @@
package payments
import (
"context"
"github.com/google/uuid"
)
// providerAdmin tags an operator-initiated refund in the ledger, distinct from a rail's own refund
// (robokassa / vk / telegram). The refund idempotency key (providerAdmin, order id) allows exactly
// one manual full refund per order.
const providerAdmin = "admin"
// RefundOrderFull refunds a paid order in full at the operator's request: it revokes the funded
// chips best-effort (floored at 0, never negative — D27), records a refund ledger row, and is
// idempotent (a second call reports AlreadyRefunded). The operator performs the actual money refund
// on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment); this records it.
func (s *Service) RefundOrderFull(ctx context.Context, orderID uuid.UUID) (RefundOutcome, error) {
o, err := s.store.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
}
refunded, err := MoneyFromMinor(o.expectedAmount, Currency(o.currency))
if err != nil {
return RefundOutcome{}, err
}
return s.store.refund(ctx, orderID, providerAdmin, orderID.String(), refunded, s.clock())
}
// LedgerExportRow is one append-only ledger row for the tax / reconciliation export, carrying the
// account it belongs to alongside the entry fields.
type LedgerExportRow struct {
AccountID string
LedgerEntry
}
// LedgerExport reads the entire append-only ledger (all accounts, newest first) for a CSV/JSON
// export — tax reporting and future rail reconciliation. Uncached, admin-only.
func (s *Service) LedgerExport(ctx context.Context) ([]LedgerExportRow, error) {
return s.store.allLedger(ctx)
}
+60
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.
@@ -156,6 +164,41 @@ func (s *Service) Grant(ctx context.Context, accountID uuid.UUID, origin Source,
return s.store.grant(ctx, accountID, origin, d, snapshot, s.clock())
}
// GrantProduct grants a product's benefit atoms (hints, no-ads days) to an origin as a zero-price
// admin sale, recording the source product on the ledger row (auditable to it). It refuses a
// product carrying the chips atom (never granted — D16) or the tournament atom (no credit target
// yet), and one whose atoms yield no grantable benefit.
func (s *Service) GrantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID) error {
if !origin.Valid() {
return fmt.Errorf("payments: invalid grant origin %q", origin)
}
in, err := s.store.productInput(ctx, productID)
if err != nil {
return err
}
var d benefitDelta
for _, a := range in.Atoms {
switch a.Atom {
case "hints":
d.hintsAdd += a.Quantity
case "noads_days":
d.noAdsDays += a.Quantity
case atomChips:
return ErrCannotGrantChips
case "tournament":
return ErrCannotGrantTournament
}
}
if d.zero() {
return ErrNothingToGrant
}
snapshot, err := marshalGrantProduct(productID, in.Title, d)
if err != nil {
return err
}
return s.store.grantProduct(ctx, accountID, origin, productID, d, snapshot, s.clock())
}
// MergeTx merges the secondary account's segments and benefits into the primary inside the
// caller's transaction (the account-merge flow). The caller invalidates the affected caches
// after committing (Invalidate).
@@ -244,3 +287,20 @@ func marshalGrant(d benefitDelta) ([]byte, error) {
}
return b, nil
}
// marshalGrantProduct builds the snapshot for an admin grant-by-product: the source product and the
// benefit atoms it granted (price 0).
func marshalGrantProduct(productID uuid.UUID, title string, d benefitDelta) ([]byte, error) {
atoms := map[string]int{}
if d.hintsAdd > 0 {
atoms["hints"] = d.hintsAdd
}
if d.noAdsDays > 0 {
atoms["noads_days"] = d.noAdsDays
}
b, err := json.Marshal(purchaseSnapshot{ProductID: productID.String(), Title: title, Atoms: atoms, PriceChips: 0})
if err != nil {
return nil, fmt.Errorf("payments: marshal product grant snapshot: %w", err)
}
return b, nil
}
@@ -81,6 +81,17 @@ func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, provide
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
}
// Refund reverses a paid order's credit best-effort, exactly once — for an external refund or an
// admin-initiated one (E7). It revokes the funded chips floored at 0 (never negative, D27), records
// any unrecoverable remainder as a per-account loss and abuse flag, and appends a refund ledger row
// idempotent on (provider, providerRefundID) — distinct from the fund's payment id. A duplicate
// refund returns AlreadyRefunded. The caller records the refunded payment event and performs any
// provider-side money-back (the rails have no unsolicited refund push: Robokassa via its refund API
// / cabinet, VK via support, Telegram via refundStarPayment — all admin-triggered).
func (s *Service) Refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money) (RefundOutcome, error) {
return s.store.refund(ctx, orderID, provider, providerRefundID, refunded, s.clock())
}
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
// to the order account's preferred language before showing the payer (the reason is displayed in the
// Telegram payment sheet).
@@ -124,6 +135,42 @@ func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, am
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
}
// providerVKAds tags a rewarded-video credit from the VK ads network in the ledger (distinct from
// the "vk" Votes-purchase provider), so the daily cap counts only ad credits and the report separates
// them.
const providerVKAds = "vk_ads"
// InterstitialCooldowns reports the post-move interstitial-ad cooldowns (seconds): global, vs_ai and
// the independent hint-triggered one. The client mirrors them and self-gates (client-mirrored, D30).
func (s *Service) InterstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
return s.store.interstitialCooldowns(ctx)
}
// RewardPayout reports the chips a rewarded-video view earns in the caller's context — the config
// payout in a trusted VK context with the VK segment attached, and 0 everywhere else (rewarded is
// VK-only, D28). The client uses it to gate the "watch for chips" button.
func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Source) (int, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return 0, nil
}
payout, _, _, err := s.store.rewardConfig(ctx)
return payout, err
}
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
// client nonce and order-less. It credits nothing when rewarded is unconfigured (0 payout) or the
// daily cap is reached. Rewarded video is VK-only (D28) and is an ad view — not a purchase — so the
// VK-iOS purchase freeze does not apply; it requires a trusted VK context with the VK segment
// attached.
func (s *Service) CreditReward(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, nonce string) (RewardOutcome, error) {
if cxt.Kind != SourceVK || !cxt.Trusted() || !has(present, SourceVK) {
return RewardOutcome{}, ErrUntrusted
}
return s.store.creditReward(ctx, accountID, SourceVK, providerVKAds, nonce, s.clock())
}
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
// still credits — see Fund).
@@ -28,7 +28,7 @@ func TestCreateOrderGateRejections(t *testing.T) {
cxt Context
}{
{"untrusted context", Context{}},
{"vk-ios spend freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
{"vk-ios purchase freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
{"method segment not attached", Context{Kind: SourceTelegram}},
}
for _, tc := range cases {
+63
View File
@@ -0,0 +1,63 @@
package payments
import (
"context"
"time"
"github.com/google/uuid"
)
// Statement is an account's full financial picture for the admin console: chip balances per funding
// segment, benefits per origin, the recorded refund risk, and the append-only ledger history
// (newest first). Read straight from the materialized tables + the ledger, uncached — an admin-only,
// rare view, not a hot path.
type Statement struct {
Segments []SegmentChips
Benefits []OriginBenefit
Risk RiskInfo
Ledger []LedgerEntry
}
// SegmentChips is one funding segment's chip balance.
type SegmentChips struct {
Source Source
Chips int
}
// OriginBenefit is one origin's benefit state: the hint wallet, the ad-free term (zero AdsPaidUntil
// when none) and the lifetime ad-free flag.
type OriginBenefit struct {
Origin Source
Hints int
AdsPaidUntil time.Time
AdsForever bool
}
// RiskInfo is the account's recorded refund risk: whether it is abuse-flagged and the unrecoverable
// chip loss accumulated by floor-0 refunds. Present is false when the account has no risk row.
type RiskInfo struct {
Present bool
Abuse bool
LossChips int
}
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
type LedgerEntry struct {
Kind string
Source string
Origin string
ChipsDelta int
ProductID string
OrderID string
Provider string
ProviderPaymentID string
Snapshot string
CreatedAt time.Time
}
// AccountStatement assembles the account's financial picture (segments, benefits, risk, full ledger
// history) for the admin console, straight from the materialized tables and the ledger (uncached).
func (s *Service) AccountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
return s.store.accountStatement(ctx, accountID)
}
@@ -0,0 +1,218 @@
package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// ErrProductTransacted is returned when a hard delete is attempted on a product an order or ledger
// row references — it must be archived instead, to keep the append-only ledger resolvable.
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
var prods []model.Product
if err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
ORDER_BY(table.Product.CreatedAt.ASC()).
QueryContext(ctx, s.db, &prods); err != nil {
return nil, fmt.Errorf("payments: admin list products: %w", err)
}
out := make([]AdminProduct, 0, len(prods))
for _, p := range prods {
atoms, prices, err := s.productComposition(ctx, p.ProductID)
if err != nil {
return nil, err
}
transacted, err := s.productTransacted(ctx, p.ProductID)
if err != nil {
return nil, err
}
out = append(out, AdminProduct{
ID: p.ProductID, Title: p.Title, Active: p.Active,
Atoms: atoms, Prices: prices, Transacted: transacted,
})
}
return out, nil
}
// productComposition reads one product's atom lines and price rows.
func (s *Store) productComposition(ctx context.Context, id uuid.UUID) ([]AtomLine, []PriceLine, error) {
var items []model.ProductItem
if err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(id))).
ORDER_BY(table.ProductItem.AtomType.ASC()).
QueryContext(ctx, s.db, &items); err != nil {
return nil, nil, fmt.Errorf("payments: load items %s: %w", id, err)
}
atoms := make([]AtomLine, len(items))
for i, it := range items {
atoms[i] = AtomLine{Atom: it.AtomType, Quantity: int(it.Quantity)}
}
var prs []model.ProductPrice
if err := postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(id))).
QueryContext(ctx, s.db, &prs); err != nil {
return nil, nil, fmt.Errorf("payments: load prices %s: %w", id, err)
}
prices := make([]PriceLine, len(prs))
for i, pr := range prs {
method := ""
if pr.Method != nil {
method = *pr.Method
}
prices[i] = PriceLine{Method: method, Currency: Currency(pr.Currency), Amount: pr.Amount}
}
return atoms, prices, nil
}
// productTransacted reports whether any order or ledger row references the product.
func (s *Store) productTransacted(ctx context.Context, id uuid.UUID) (bool, error) {
var yes bool
if err := s.db.QueryRowContext(ctx,
`SELECT EXISTS(SELECT 1 FROM payments.orders WHERE product_id=$1)
OR EXISTS(SELECT 1 FROM payments.ledger WHERE product_id=$1)`, id).Scan(&yes); err != nil {
return false, fmt.Errorf("payments: product transacted %s: %w", id, err)
}
return yes, nil
}
// productActive reads a product's archived flag, ErrProductNotFound when it is missing.
func (s *Store) productActive(ctx context.Context, id uuid.UUID) (bool, error) {
var p model.Product
err := postgres.SELECT(table.Product.Active).FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) {
return false, ErrProductNotFound
}
if err != nil {
return false, fmt.Errorf("payments: product active %s: %w", id, err)
}
return p.Active, nil
}
// productInput reads a product's current editable content (title, atoms, prices).
func (s *Store) productInput(ctx context.Context, id uuid.UUID) (ProductInput, error) {
var p model.Product
err := postgres.SELECT(table.Product.AllColumns).FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(id))).LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) {
return ProductInput{}, ErrProductNotFound
}
if err != nil {
return ProductInput{}, fmt.Errorf("payments: product input %s: %w", id, err)
}
atoms, prices, err := s.productComposition(ctx, id)
if err != nil {
return ProductInput{}, err
}
return ProductInput{Title: p.Title, Atoms: atoms, Prices: prices}, nil
}
// createProduct inserts a product with its atoms and prices in one transaction, returning its id.
func (s *Store) createProduct(ctx context.Context, in ProductInput, active bool, now time.Time) (uuid.UUID, error) {
id := uuid.New()
if err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product (product_id, title, active, created_at, updated_at) VALUES ($1,$2,$3,$4,$4)`,
id, in.Title, active, now); err != nil {
return fmt.Errorf("insert product: %w", err)
}
return insertComposition(ctx, tx, id, in)
}); err != nil {
return uuid.Nil, fmt.Errorf("payments: create product: %w", err)
}
return id, nil
}
// updateProduct replaces a product's title, atoms and prices in one transaction (active unchanged).
func (s *Store) updateProduct(ctx context.Context, id uuid.UUID, in ProductInput, now time.Time) error {
return withTx(ctx, s.db, func(tx *sql.Tx) error {
res, err := tx.ExecContext(ctx,
`UPDATE payments.product SET title=$2, updated_at=$3 WHERE product_id=$1`, id, in.Title, now)
if err != nil {
return fmt.Errorf("payments: update product %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_item WHERE product_id=$1`, id); err != nil {
return fmt.Errorf("payments: clear items %s: %w", id, err)
}
if _, err := tx.ExecContext(ctx, `DELETE FROM payments.product_price WHERE product_id=$1`, id); err != nil {
return fmt.Errorf("payments: clear prices %s: %w", id, err)
}
return insertComposition(ctx, tx, id, in)
})
}
// insertComposition inserts a product's atom items and price rows inside tx (a value's CHIP price
// carries a NULL method).
func insertComposition(ctx context.Context, tx *sql.Tx, id uuid.UUID, in ProductInput) error {
for _, a := range in.Atoms {
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product_item (product_id, atom_type, quantity) VALUES ($1,$2,$3)`,
id, a.Atom, a.Quantity); err != nil {
return fmt.Errorf("insert item %s: %w", a.Atom, err)
}
}
for _, p := range in.Prices {
var method any
if p.Method != "" {
method = p.Method
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO payments.product_price (product_id, method, currency, amount) VALUES ($1,$2,$3,$4)`,
id, method, string(p.Currency), p.Amount); err != nil {
return fmt.Errorf("insert price %s: %w", p.Currency, err)
}
}
return nil
}
// setProductActive flips the archived flag.
func (s *Store) setProductActive(ctx context.Context, id uuid.UUID, active bool, now time.Time) error {
res, err := s.db.ExecContext(ctx,
`UPDATE payments.product SET active=$2, updated_at=$3 WHERE product_id=$1`, id, active, now)
if err != nil {
return fmt.Errorf("payments: set product active %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
return nil
}
// deleteProduct hard-deletes a never-transacted product (its items/prices cascade); a transacted
// product is refused with ErrProductTransacted.
func (s *Store) deleteProduct(ctx context.Context, id uuid.UUID) error {
transacted, err := s.productTransacted(ctx, id)
if err != nil {
return err
}
if transacted {
return ErrProductTransacted
}
res, err := s.db.ExecContext(ctx, `DELETE FROM payments.product WHERE product_id=$1`, id)
if err != nil {
return fmt.Errorf("payments: delete product %s: %w", id, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrProductNotFound
}
return nil
}
+242
View File
@@ -27,6 +27,9 @@ var (
// ErrAmountMismatch means the callback's paid amount or currency does not match the order's
// expected amount — the credit is refused (§9: verify amount after matching by order id).
ErrAmountMismatch = errors.New("payments: paid amount does not match the order")
// ErrOrderNotPaid means a refund targets an order that was never funded — there is nothing to
// reverse (guards against a spurious loss/abuse record on an unpaid order).
ErrOrderNotPaid = errors.New("payments: order is not paid")
)
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
@@ -34,6 +37,10 @@ var (
// a replayed callback is a success that credits nothing.
var errAlreadyCredited = errors.New("payments: already credited")
// errAlreadyRefunded unwinds the refund transaction when the ledger idempotency index rejects a
// duplicate refund (same provider refund id). It is not surfaced: a replayed refund reverses nothing.
var errAlreadyRefunded = errors.New("payments: already refunded")
// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in
// the requested payment method's currency.
type packInfo struct {
@@ -271,6 +278,207 @@ func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerP
return outcome, nil
}
// RefundOutcome reports a refund's result: whose funded segment was reversed, the chips actually
// clawed back (floored at 0), the unrecoverable remainder (a loss, when the chips were already
// spent), and whether the refund was a duplicate that reversed nothing.
type RefundOutcome struct {
AccountID uuid.UUID
Source Source
Revoked int
Loss int
AlreadyRefunded bool
}
// refund reverses a paid order's credit best-effort, exactly once. It matches the order (which must
// be paid), verifies the refunded amount, then in one transaction appends a refund ledger row
// (idempotent on the (provider, provider_payment_id) index — the refund id is distinct from the
// fund's payment id, so the two rows coexist), revokes the funded chips floored at 0 (never
// negative, D27/balances_chips_chk) and, when chips were already spent, records the unrecoverable
// remainder as a per-account loss and flips the abuse flag. A duplicate refund returns
// AlreadyRefunded with no second reversal. The ledger row's chipsDelta is what is actually
// reclaimed; the full reversal (money, original chips, loss) rides in its snapshot for the report.
func (s *Store) refund(ctx context.Context, orderID uuid.UUID, provider, providerRefundID string, refunded Money, now time.Time) (RefundOutcome, error) {
ord, err := s.orderByID(ctx, orderID)
if err != nil {
return RefundOutcome{}, err
}
if ord.status != "paid" {
return RefundOutcome{}, ErrOrderNotPaid
}
if refunded.Currency() != Currency(ord.currency) || refunded.Minor() != ord.expectedAmount {
return RefundOutcome{}, ErrAmountMismatch
}
chips, title, err := s.packForCredit(ctx, ord.productID)
if err != nil {
return RefundOutcome{}, err
}
src := Source(ord.origin)
outcome := RefundOutcome{AccountID: ord.accountID, Source: src}
pv, pr := provider, providerRefundID
productID := ord.productID
oid := orderID
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Lock the funded segment and read what is left; a spent balance floors the reversal at 0.
var avail int
e := tx.QueryRowContext(ctx,
`SELECT chips FROM payments.balances WHERE account_id = $1 AND source = $2 FOR UPDATE`,
ord.accountID, string(src)).Scan(&avail)
switch {
case errors.Is(e, sql.ErrNoRows):
avail = 0
case e != nil:
return fmt.Errorf("payments: read balance for refund: %w", e)
}
revoked := min(chips, avail)
loss := chips - revoked
outcome.Revoked, outcome.Loss = revoked, loss
snapshot, e := marshalRefundSnapshot(ord.productID, title, chips, revoked, loss, refunded, providerRefundID)
if e != nil {
return e
}
if e := insertLedgerTx(ctx, tx, ord.accountID, "refund", &src, &src, -revoked, &productID, &oid, &pv, &pr, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyRefunded = true
return errAlreadyRefunded
}
return e
}
if revoked > 0 {
if _, e := tx.ExecContext(ctx,
`UPDATE payments.balances SET chips = chips - $3, updated_at = now()
WHERE account_id = $1 AND source = $2`,
ord.accountID, string(src), revoked); e != nil {
return fmt.Errorf("payments: revoke chips %s: %w", src, e)
}
}
if loss > 0 {
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.account_risk (account_id, abuse, loss_chips, updated_at)
VALUES ($1, true, $2, now())
ON CONFLICT (account_id) DO UPDATE
SET abuse = true, loss_chips = payments.account_risk.loss_chips + EXCLUDED.loss_chips, updated_at = now()`,
ord.accountID, int64(loss)); e != nil {
return fmt.Errorf("payments: record refund loss: %w", e)
}
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyRefunded) {
return outcome, nil
}
return RefundOutcome{}, err
}
s.cache.invalidate(ord.accountID)
return outcome, nil
}
// RewardOutcome reports a rewarded-video credit: the chips credited (0 when rewarded is unconfigured
// or the daily cap is reached), whether the daily cap blocked it, and whether it was a duplicate view
// (same client nonce) that credited nothing more.
type RewardOutcome struct {
AccountID uuid.UUID
Chips int
Capped bool
AlreadyCredited bool
}
// interstitialCooldowns reads the post-move interstitial-ad cooldowns (seconds): the global
// per-user cooldown, the longer vs_ai one, and the independent hint-triggered one. The client mirrors
// them and self-gates (E6/D30).
func (s *Store) interstitialCooldowns(ctx context.Context) (global, vsAi, hint int, err error) {
var cfg model.Config
if e := postgres.SELECT(table.Config.CooldownGlobalSeconds, table.Config.CooldownVsAiSeconds, table.Config.CooldownHintSeconds).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); e != nil {
return 0, 0, 0, fmt.Errorf("payments: read interstitial cooldowns: %w", e)
}
return int(cfg.CooldownGlobalSeconds), int(cfg.CooldownVsAiSeconds), int(cfg.CooldownHintSeconds), nil
}
// rewardConfig reads the rewarded payout (chips per view) and the per-day and per-hour caps. The
// caps are both anti-abuse (bounding a forger's free chips) and an economic conversion lever (free
// rewarded chips are limited so a player who wants more buys) — tuned in the admin.
func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
var cfg model.Config
if e := postgres.SELECT(table.Config.RewardedPayoutChips, table.Config.RewardDailyCap, table.Config.RewardHourlyCap).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); e != nil {
return 0, 0, 0, fmt.Errorf("payments: read reward config: %w", e)
}
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
}
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
// (provider, provider_payment_id) index), so a retried view credits once, and order-less (a free
// credit, no order). The cap counts today's rewarded credits for this network (UTC day); a rare
// concurrent race may allow cap+1, which the per-user rate limiter bounds and the cap tolerates.
func (s *Store) creditReward(ctx context.Context, accountID uuid.UUID, source Source, provider, nonce string, now time.Time) (RewardOutcome, error) {
payout, dailyCap, hourlyCap, err := s.rewardConfig(ctx)
if err != nil {
return RewardOutcome{}, err
}
outcome := RewardOutcome{AccountID: accountID}
if payout <= 0 {
return outcome, nil // rewarded not configured (0 payout) — inert until the owner sets it
}
// Count this network's rewarded credits in the last day and last hour (one scan over the last
// 25 h covers both windows); either cap reached blocks the credit. A rare concurrent race may
// allow cap+1, which the per-user rate limiter bounds and the anti-abuse cap tolerates.
var today, lastHour int
if e := s.db.QueryRowContext(ctx,
`SELECT count(*) FILTER (WHERE created_at >= date_trunc('day', now())),
count(*) FILTER (WHERE created_at >= now() - interval '1 hour')
FROM payments.ledger
WHERE account_id = $1 AND kind = 'fund' AND provider = $2 AND created_at >= now() - interval '25 hours'`,
accountID, provider).Scan(&today, &lastHour); e != nil {
return RewardOutcome{}, fmt.Errorf("payments: count rewarded views: %w", e)
}
if today >= dailyCap || lastHour >= hourlyCap {
outcome.Capped = true
return outcome, nil
}
snapshot, err := marshalRewardSnapshot(payout)
if err != nil {
return RewardOutcome{}, err
}
src := source
pv, pp := provider, nonce
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if e := insertLedgerTx(ctx, tx, accountID, "fund", &src, &src, payout, nil, nil, &pv, &pp, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyCredited = true
return errAlreadyCredited
}
return e
}
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (account_id, source) DO UPDATE
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
accountID, string(src), payout); e != nil {
return fmt.Errorf("payments: credit rewarded balance: %w", e)
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyCredited) {
return outcome, nil
}
return RewardOutcome{}, err
}
outcome.Chips = payout
s.cache.invalidate(accountID)
return outcome, nil
}
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
@@ -373,6 +581,40 @@ func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Mone
return b, nil
}
// marshalRefundSnapshot records the full reversal on the refund ledger row: the pack, the original
// funded chips, how many were actually reclaimed, the unrecoverable loss (already spent), the money
// refunded and the provider refund id — so the ledger stays reconcilable against the balance
// (chipsDelta = revoked) while the report still sees the whole reversal (§7/D27/D34).
func marshalRefundSnapshot(productID uuid.UUID, title string, chips, revoked, loss int, refunded Money, refundID string) ([]byte, error) {
b, err := json.Marshal(struct {
ProductID string `json:"product_id"`
Title string `json:"title,omitempty"`
Chips int `json:"chips"`
Revoked int `json:"revoked"`
Loss int `json:"loss"`
Amount int64 `json:"amount_minor"`
Currency string `json:"currency"`
RefundID string `json:"refund_id"`
}{productID.String(), title, chips, revoked, loss, refunded.Minor(), string(refunded.Currency()), refundID})
if err != nil {
return nil, fmt.Errorf("payments: marshal refund snapshot: %w", err)
}
return b, nil
}
// marshalRewardSnapshot records a rewarded-video credit on its ledger row: the marker distinguishing
// it from a paid fund, and the chips granted — so the report separates ad-earned chips from purchases.
func marshalRewardSnapshot(chips int) ([]byte, error) {
b, err := json.Marshal(struct {
Reward bool `json:"reward"`
Chips int `json:"chips"`
}{true, chips})
if err != nil {
return nil, fmt.Errorf("payments: marshal reward snapshot: %w", err)
}
return b, nil
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
func isUniqueViolation(err error) bool {
@@ -0,0 +1,132 @@
package payments
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// statementOrder is the fixed segment/origin ordering the report renders, so the panel is stable
// (the balance/benefit maps iterate randomly).
var statementOrder = []Source{SourceDirect, SourceVK, SourceTelegram}
// accountStatement reads the account's balances, benefits, refund risk and full ledger history for
// the admin report. Uncached and outside any transaction — an operator view, not a hot path.
func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Statement, error) {
st, err := s.loadState(ctx, accountID)
if err != nil {
return Statement{}, err
}
var out Statement
for _, src := range statementOrder {
if chips, ok := st.chips[src]; ok {
out.Segments = append(out.Segments, SegmentChips{Source: src, Chips: chips})
}
if b, ok := st.benefits[src]; ok {
out.Benefits = append(out.Benefits, OriginBenefit{
Origin: src, Hints: b.hints, AdsPaidUntil: derefTime(b.adsPaidUntil), AdsForever: b.adsForever,
})
}
}
var risk model.AccountRisk
err = postgres.SELECT(table.AccountRisk.AllColumns).
FROM(table.AccountRisk).
WHERE(table.AccountRisk.AccountID.EQ(postgres.UUID(accountID))).
LIMIT(1).
QueryContext(ctx, s.db, &risk)
switch {
case err == nil:
out.Risk = RiskInfo{Present: true, Abuse: risk.Abuse, LossChips: int(risk.LossChips)}
case errors.Is(err, qrm.ErrNoRows):
// no risk row — a clean account
default:
return Statement{}, fmt.Errorf("payments: load risk %s: %w", accountID, err)
}
var rows []model.Ledger
if err := postgres.SELECT(table.Ledger.AllColumns).
FROM(table.Ledger).
WHERE(table.Ledger.AccountID.EQ(postgres.UUID(accountID))).
ORDER_BY(table.Ledger.CreatedAt.DESC()).
QueryContext(ctx, s.db, &rows); err != nil {
return Statement{}, fmt.Errorf("payments: load ledger %s: %w", accountID, err)
}
for _, r := range rows {
out.Ledger = append(out.Ledger, LedgerEntry{
Kind: r.Kind,
Source: derefStr(r.Source),
Origin: derefStr(r.Origin),
ChipsDelta: int(r.ChipsDelta),
ProductID: derefUUID(r.ProductID),
OrderID: derefUUID(r.OrderID),
Provider: derefStr(r.Provider),
ProviderPaymentID: derefStr(r.ProviderPaymentID),
Snapshot: derefStr(r.Snapshot),
CreatedAt: r.CreatedAt,
})
}
return out, nil
}
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
var rows []model.Ledger
if err := postgres.SELECT(table.Ledger.AllColumns).
FROM(table.Ledger).
ORDER_BY(table.Ledger.CreatedAt.DESC()).
QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("payments: export ledger: %w", err)
}
out := make([]LedgerExportRow, 0, len(rows))
for _, r := range rows {
out = append(out, LedgerExportRow{
AccountID: r.AccountID.String(),
LedgerEntry: LedgerEntry{
Kind: r.Kind,
Source: derefStr(r.Source),
Origin: derefStr(r.Origin),
ChipsDelta: int(r.ChipsDelta),
ProductID: derefUUID(r.ProductID),
OrderID: derefUUID(r.OrderID),
Provider: derefStr(r.Provider),
ProviderPaymentID: derefStr(r.ProviderPaymentID),
Snapshot: derefStr(r.Snapshot),
CreatedAt: r.CreatedAt,
},
})
}
return out, nil
}
// derefStr returns the pointed-to string, or "" when the pointer is nil (a NULL column).
func derefStr(p *string) string {
if p == nil {
return ""
}
return *p
}
// derefUUID renders the pointed-to UUID as a string, or "" when the pointer is nil.
func derefUUID(p *uuid.UUID) string {
if p == nil {
return ""
}
return p.String()
}
// derefTime returns the pointed-to time, or the zero time when the pointer is nil (a NULL column).
func derefTime(p *time.Time) time.Time {
if p == nil {
return time.Time{}
}
return *p
}
+24
View File
@@ -26,6 +26,14 @@ var (
// ErrNotAValue means the product has no chip price (it is a chip pack or unpriced), so it
// cannot be bought with chips.
ErrNotAValue = errors.New("payments: product is not a chip-priced value")
// ErrCannotGrantChips means an admin grant targeted a product carrying the chips atom — the
// admin never grants currency (a gifted balance would bypass the cash desk, D16).
ErrCannotGrantChips = errors.New("payments: cannot grant chips")
// ErrCannotGrantTournament means an admin grant targeted a product carrying the tournament atom,
// which has no credit target until the tournament stage.
ErrCannotGrantTournament = errors.New("payments: cannot grant a tournament atom yet")
// ErrNothingToGrant means the product's atoms yield no grantable benefit (hints / no-ads days).
ErrNothingToGrant = errors.New("payments: product has nothing to grant")
)
// withTx runs fn inside a transaction on db, rolling back on error or panic.
@@ -312,6 +320,22 @@ func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d
return nil
}
// grantProduct is grant with the source product recorded on the ledger row (product_id), for an
// admin grant-by-product — the benefit is the product's atoms, the ledger stays auditable to it.
func (s *Store) grantProduct(ctx context.Context, accountID uuid.UUID, origin Source, productID uuid.UUID, d benefitDelta, snapshot []byte, now time.Time) error {
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, &productID, nil, nil, nil, snapshot, now); err != nil {
return err
}
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
})
if err != nil {
return err
}
s.cache.invalidate(accountID)
return nil
}
// consumeHint decrements one hint from the first applicable origin (in the given priority order)
// that has one, with a guarded update. It returns whether a hint was spent.
func (s *Store) consumeHint(ctx context.Context, accountID uuid.UUID, origins []Source, now time.Time) (bool, error) {
+4 -3
View File
@@ -102,10 +102,11 @@ func TestWalletSegments(t *testing.T) {
if len(got.Segments) != 1 || got.Segments[0].Source != SourceVK || !got.Segments[0].Spendable {
t.Errorf("vk-android wallet = %+v", got.Segments)
}
// VK iOS: only vk shown, frozen (not spendable) but the balance is visible.
// VK iOS: only vk shown, and spendable — the freeze is purchase-only, so VK-wallet chips still
// spend there (only buying more chips for money is blocked).
got, _ = svc.Wallet(context.Background(), id, NewContext("vk", "ios"), present)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 frozen)", got.Segments)
if len(got.Segments) != 1 || got.Segments[0].Chips != 50 || !got.Segments[0].Spendable {
t.Errorf("vk-ios wallet = %+v (want vk 50 spendable)", got.Segments)
}
}
@@ -0,0 +1,18 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
type Config struct {
OnlyRow bool `sql:"primary_key"`
GuestVsAiLimit int16
GuestRandomLimit int16
GuestFriendsLimit int16
DurableVsAiLimit int16
DurableRandomLimit int16
DurableFriendsLimit int16
}
@@ -33,4 +33,5 @@ type Games struct {
DropoutTiles string
MultipleWordsPerTurn bool
VsAi bool
GameKind int16
}
@@ -0,0 +1,96 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var Config = newConfigTable("backend", "config", "")
type configTable struct {
postgres.Table
// Columns
OnlyRow postgres.ColumnBool
GuestVsAiLimit postgres.ColumnInteger
GuestRandomLimit postgres.ColumnInteger
GuestFriendsLimit postgres.ColumnInteger
DurableVsAiLimit postgres.ColumnInteger
DurableRandomLimit postgres.ColumnInteger
DurableFriendsLimit postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type ConfigTable struct {
configTable
EXCLUDED configTable
}
// AS creates new ConfigTable with assigned alias
func (a ConfigTable) AS(alias string) *ConfigTable {
return newConfigTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new ConfigTable with assigned schema name
func (a ConfigTable) FromSchema(schemaName string) *ConfigTable {
return newConfigTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new ConfigTable with assigned table prefix
func (a ConfigTable) WithPrefix(prefix string) *ConfigTable {
return newConfigTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new ConfigTable with assigned table suffix
func (a ConfigTable) WithSuffix(suffix string) *ConfigTable {
return newConfigTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newConfigTable(schemaName, tableName, alias string) *ConfigTable {
return &ConfigTable{
configTable: newConfigTableImpl(schemaName, tableName, alias),
EXCLUDED: newConfigTableImpl("", "excluded", ""),
}
}
func newConfigTableImpl(schemaName, tableName, alias string) configTable {
var (
OnlyRowColumn = postgres.BoolColumn("only_row")
GuestVsAiLimitColumn = postgres.IntegerColumn("guest_vs_ai_limit")
GuestRandomLimitColumn = postgres.IntegerColumn("guest_random_limit")
GuestFriendsLimitColumn = postgres.IntegerColumn("guest_friends_limit")
DurableVsAiLimitColumn = postgres.IntegerColumn("durable_vs_ai_limit")
DurableRandomLimitColumn = postgres.IntegerColumn("durable_random_limit")
DurableFriendsLimitColumn = postgres.IntegerColumn("durable_friends_limit")
allColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
mutableColumns = postgres.ColumnList{GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, GuestVsAiLimitColumn, GuestRandomLimitColumn, GuestFriendsLimitColumn, DurableVsAiLimitColumn, DurableRandomLimitColumn, DurableFriendsLimitColumn}
)
return configTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
OnlyRow: OnlyRowColumn,
GuestVsAiLimit: GuestVsAiLimitColumn,
GuestRandomLimit: GuestRandomLimitColumn,
GuestFriendsLimit: GuestFriendsLimitColumn,
DurableVsAiLimit: DurableVsAiLimitColumn,
DurableRandomLimit: DurableRandomLimitColumn,
DurableFriendsLimit: DurableFriendsLimitColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -37,6 +37,7 @@ type gamesTable struct {
DropoutTiles postgres.ColumnString
MultipleWordsPerTurn postgres.ColumnBool
VsAi postgres.ColumnBool
GameKind postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -98,9 +99,10 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
DropoutTilesColumn = postgres.StringColumn("dropout_tiles")
MultipleWordsPerTurnColumn = postgres.BoolColumn("multiple_words_per_turn")
VsAiColumn = postgres.BoolColumn("vs_ai")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn}
GameKindColumn = postgres.IntegerColumn("game_kind")
allColumns = postgres.ColumnList{GameIDColumn, VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
mutableColumns = postgres.ColumnList{VariantColumn, DictVersionColumn, SeedColumn, StatusColumn, PlayersColumn, ToMoveColumn, TurnStartedAtColumn, TurnTimeoutSecsColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, EndReasonColumn, CreatedAtColumn, UpdatedAtColumn, FinishedAtColumn, OpenDeadlineAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
defaultColumns = postgres.ColumnList{StatusColumn, ToMoveColumn, TurnStartedAtColumn, HintsAllowedColumn, HintsPerPlayerColumn, MoveCountColumn, CreatedAtColumn, UpdatedAtColumn, DropoutTilesColumn, MultipleWordsPerTurnColumn, VsAiColumn, GameKindColumn}
)
return gamesTable{
@@ -127,6 +129,7 @@ func newGamesTableImpl(schemaName, tableName, alias string) gamesTable {
DropoutTiles: DropoutTilesColumn,
MultipleWordsPerTurn: MultipleWordsPerTurnColumn,
VsAi: VsAiColumn,
GameKind: GameKindColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -21,6 +21,7 @@ func UseSchema(schema string) {
Blocks = Blocks.FromSchema(schema)
ChatMessages = ChatMessages.FromSchema(schema)
Complaints = Complaints.FromSchema(schema)
Config = Config.FromSchema(schema)
DictionaryState = DictionaryState.FromSchema(schema)
EmailConfirmations = EmailConfirmations.FromSchema(schema)
FeedbackMessages = FeedbackMessages.FromSchema(schema)
@@ -0,0 +1,20 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package model
import (
"github.com/google/uuid"
"time"
)
type AccountRisk struct {
AccountID uuid.UUID `sql:"primary_key"`
Abuse bool
LossChips int64
UpdatedAt time.Time
}
@@ -14,4 +14,6 @@ type Config struct {
CooldownVsAiSeconds int32
CooldownHintSeconds int32
OrderTTLSeconds int32
RewardDailyCap int32
RewardHourlyCap int32
}
@@ -0,0 +1,87 @@
//
// Code generated by go-jet DO NOT EDIT.
//
// WARNING: Changes to this file may cause incorrect behavior
// and will be lost if the code is regenerated
//
package table
import (
"github.com/go-jet/jet/v2/postgres"
)
var AccountRisk = newAccountRiskTable("payments", "account_risk", "")
type accountRiskTable struct {
postgres.Table
// Columns
AccountID postgres.ColumnString
Abuse postgres.ColumnBool
LossChips postgres.ColumnInteger
UpdatedAt postgres.ColumnTimestampz
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
DefaultColumns postgres.ColumnList
}
type AccountRiskTable struct {
accountRiskTable
EXCLUDED accountRiskTable
}
// AS creates new AccountRiskTable with assigned alias
func (a AccountRiskTable) AS(alias string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), a.TableName(), alias)
}
// Schema creates new AccountRiskTable with assigned schema name
func (a AccountRiskTable) FromSchema(schemaName string) *AccountRiskTable {
return newAccountRiskTable(schemaName, a.TableName(), a.Alias())
}
// WithPrefix creates new AccountRiskTable with assigned table prefix
func (a AccountRiskTable) WithPrefix(prefix string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
}
// WithSuffix creates new AccountRiskTable with assigned table suffix
func (a AccountRiskTable) WithSuffix(suffix string) *AccountRiskTable {
return newAccountRiskTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
}
func newAccountRiskTable(schemaName, tableName, alias string) *AccountRiskTable {
return &AccountRiskTable{
accountRiskTable: newAccountRiskTableImpl(schemaName, tableName, alias),
EXCLUDED: newAccountRiskTableImpl("", "excluded", ""),
}
}
func newAccountRiskTableImpl(schemaName, tableName, alias string) accountRiskTable {
var (
AccountIDColumn = postgres.StringColumn("account_id")
AbuseColumn = postgres.BoolColumn("abuse")
LossChipsColumn = postgres.IntegerColumn("loss_chips")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
allColumns = postgres.ColumnList{AccountIDColumn, AbuseColumn, LossChipsColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{AbuseColumn, LossChipsColumn, UpdatedAtColumn}
)
return accountRiskTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns
AccountID: AccountIDColumn,
Abuse: AbuseColumn,
LossChips: LossChipsColumn,
UpdatedAt: UpdatedAtColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
DefaultColumns: defaultColumns,
}
}
@@ -23,6 +23,8 @@ type configTable struct {
CooldownVsAiSeconds postgres.ColumnInteger
CooldownHintSeconds postgres.ColumnInteger
OrderTTLSeconds postgres.ColumnInteger
RewardDailyCap postgres.ColumnInteger
RewardHourlyCap postgres.ColumnInteger
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -70,9 +72,11 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSecondsColumn = postgres.IntegerColumn("cooldown_vs_ai_seconds")
CooldownHintSecondsColumn = postgres.IntegerColumn("cooldown_hint_seconds")
OrderTTLSecondsColumn = postgres.IntegerColumn("order_ttl_seconds")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn}
RewardDailyCapColumn = postgres.IntegerColumn("reward_daily_cap")
RewardHourlyCapColumn = postgres.IntegerColumn("reward_hourly_cap")
allColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
mutableColumns = postgres.ColumnList{RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
defaultColumns = postgres.ColumnList{OnlyRowColumn, RewardedPayoutChipsColumn, CooldownGlobalSecondsColumn, CooldownVsAiSecondsColumn, CooldownHintSecondsColumn, OrderTTLSecondsColumn, RewardDailyCapColumn, RewardHourlyCapColumn}
)
return configTable{
@@ -85,6 +89,8 @@ func newConfigTableImpl(schemaName, tableName, alias string) configTable {
CooldownVsAiSeconds: CooldownVsAiSecondsColumn,
CooldownHintSeconds: CooldownHintSecondsColumn,
OrderTTLSeconds: OrderTTLSecondsColumn,
RewardDailyCap: RewardDailyCapColumn,
RewardHourlyCap: RewardHourlyCapColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
@@ -10,6 +10,7 @@ package table
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
// this method only once at the beginning of the program.
func UseSchema(schema string) {
AccountRisk = AccountRisk.FromSchema(schema)
Balances = Balances.FromSchema(schema)
Benefits = Benefits.FromSchema(schema)
CatalogAtom = CatalogAtom.FromSchema(schema)
@@ -0,0 +1,25 @@
-- Per-account payment risk: the loss and abuse signal an external/admin refund leaves
-- behind when the refunded chips were already spent. A refund revokes chips best-effort
-- and never drives a balance negative (D27, balances_chips_chk); the unrecoverable
-- remainder is a recorded loss and flips an abuse flag the /_gm financial report reads
-- (D40, E7). Mutable per-account state (upserted on each such refund), so — unlike the
-- ledger — it carries no append-only trigger. Additive: a new table only, so goose
-- applies it forward with no rewrite of existing data (the contour is not wiped). The
-- payments role inherits ALL on it via the schema default privileges set in 00010.
-- +goose Up
CREATE TABLE payments.account_risk (
account_id uuid NOT NULL,
-- abuse flips true the first time a refund cannot fully reclaim its chips (spent).
abuse boolean DEFAULT false NOT NULL,
-- loss_chips accumulates the unrecoverable chips across such refunds (bigint: an
-- accumulator, mapped to int64 by go-jet — not the numeric->float64 trap).
loss_chips bigint DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT account_risk_pkey PRIMARY KEY (account_id),
CONSTRAINT account_risk_loss_chips_chk CHECK ((loss_chips >= 0))
);
-- +goose Down
DROP TABLE IF EXISTS payments.account_risk;
@@ -0,0 +1,22 @@
-- Rewarded-video caps: the anti-abuse ceilings on free rewarded credits per user, per
-- day and per hour. VK Mini App ads expose only a client-side watch result (no
-- server-to-server verify), so a rewarded credit is client-attested; the caps bound a
-- forger who skips the ad and calls the credit endpoint directly (the daily cap bounds the
-- total; the hourly cap smooths a burst). Chips-per-view already exists
-- (rewarded_payout_chips, default 0 = rewarded inert until the owner sets it). All are
-- config, tuned in the admin without a release. Additive columns only — applies forward via
-- goose with no data rewrite (no contour wipe), and an image rollback ignores them.
-- +goose Up
ALTER TABLE payments.config
ADD COLUMN reward_daily_cap integer DEFAULT 50 NOT NULL,
ADD COLUMN reward_hourly_cap integer DEFAULT 10 NOT NULL;
ALTER TABLE payments.config
ADD CONSTRAINT config_reward_daily_cap_chk CHECK (reward_daily_cap >= 0),
ADD CONSTRAINT config_reward_hourly_cap_chk CHECK (reward_hourly_cap >= 0);
-- +goose Down
ALTER TABLE payments.config
DROP COLUMN reward_daily_cap,
DROP COLUMN reward_hourly_cap;
@@ -0,0 +1,36 @@
-- Guest-limit foundation (E8): tag each game with its kind so the per-tier, per-kind active-game
-- limits are enforceable, and add the single-row config that holds those limits (tuned in the admin,
-- no release). It replaces the old hardcoded MaxActiveQuickGames=10 combined cap with a per-tier,
-- per-kind config. game_kind: 0=unknown (pre-E8 games, never gated), 1=vs_ai, 2=random, 3=friends —
-- set on creation. The limits are smallint with -1 = unlimited; a guest defaults to 1 vs_ai + 1
-- random (friends 0, moot — the guest gate blocks friend games), a durable account to 10 per kind
-- (the old cap, now per kind). Additive only — applies forward via goose with no data rewrite (no
-- contour wipe); an image rollback ignores the column + table.
-- +goose Up
ALTER TABLE backend.games
ADD COLUMN game_kind smallint DEFAULT 0 NOT NULL;
ALTER TABLE backend.games
ADD CONSTRAINT games_game_kind_chk CHECK (game_kind >= 0 AND game_kind <= 3);
CREATE TABLE backend.config (
only_row boolean DEFAULT true NOT NULL,
guest_vs_ai_limit smallint DEFAULT 1 NOT NULL,
guest_random_limit smallint DEFAULT 1 NOT NULL,
guest_friends_limit smallint DEFAULT 0 NOT NULL,
durable_vs_ai_limit smallint DEFAULT 10 NOT NULL,
durable_random_limit smallint DEFAULT 10 NOT NULL,
durable_friends_limit smallint DEFAULT 10 NOT NULL,
CONSTRAINT config_pkey PRIMARY KEY (only_row),
CONSTRAINT config_single_row_chk CHECK (only_row),
CONSTRAINT config_limits_chk CHECK (
guest_vs_ai_limit >= -1 AND guest_random_limit >= -1 AND guest_friends_limit >= -1 AND
durable_vs_ai_limit >= -1 AND durable_random_limit >= -1 AND durable_friends_limit >= -1)
);
INSERT INTO backend.config (only_row) VALUES (true);
-- +goose Down
DROP TABLE backend.config;
ALTER TABLE backend.games DROP COLUMN game_kind;
+43 -3
View File
@@ -57,9 +57,9 @@ type bannerTimingsDTO struct {
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc)
// Resolve the payments gate once (execution context + present sources) and feed it to both
// the hint count and the banner. The profile hint balance now comes from the payments benefit
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy
// value from profileResponseFor (zeroed in production) stands.
// the hint count and the banner. The profile hint balance comes from the payments benefit
// (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any
// failure the fallback from profileResponseFor is a plain 0.
cxt, present, err := s.walletGate(ctx, acc.ID)
if err != nil {
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
@@ -71,6 +71,8 @@ func (s *Server) profileResponse(ctx context.Context, acc account.Account) profi
}
}
r.Banner = s.bannerFor(ctx, acc, cxt, present)
r.Ads = s.adsFor(ctx, acc, cxt, present)
r.GameLimits = s.gameLimitsDTOFor(acc.IsGuest)
s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions()
return r
@@ -177,6 +179,44 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payment
}
}
// adsDTO is the post-move interstitial config in the profile: the client-mirrored cooldowns
// (seconds) and whether ads are suppressed in the context (a no-ads benefit applicable here, or the
// no_banner role). The client shows a VK interstitial after a confirmed move / hint only when not
// suppressed and the mirrored cooldown has elapsed.
type adsDTO struct {
CooldownGlobalS int `json:"cooldown_global_s"`
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
CooldownHintS int `json:"cooldown_hint_s"`
Suppressed bool `json:"suppressed"`
}
// adsFor builds the profile interstitial-ad config: the cooldowns and whether ads are suppressed
// here (the same no-ads / no_banner gate as the banner). A read failure logs and yields a suppressed
// block (fail-safe: no interstitial), so the profile still succeeds.
func (s *Server) adsFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *adsDTO {
if s.payments == nil {
return nil
}
global, vsAi, hint, err := s.payments.InterstitialCooldowns(ctx)
if err != nil {
s.log.Warn("profile: ad cooldowns read failed", zap.String("account", acc.ID.String()), zap.Error(err))
return &adsDTO{Suppressed: true}
}
suppressed := false
if adFree, aerr := s.payments.AdFree(ctx, acc.ID, cxt, present); aerr != nil {
s.log.Warn("profile: ad-free read failed", zap.String("account", acc.ID.String()), zap.Error(aerr))
suppressed = true // fail-safe: suppress the interstitial when eligibility is unknown
} else {
suppressed = adFree
}
if !suppressed {
if noBanner, berr := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner); berr == nil {
suppressed = noBanner
}
}
return &adsDTO{CooldownGlobalS: global, CooldownVsAiS: vsAi, CooldownHintS: hint, Suppressed: suppressed}
}
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
// projecting each optional colour set into its three "#rrggbb" fields (empty when
// the set is absent, so JSON omitempty drops them).
+32 -10
View File
@@ -60,6 +60,11 @@ type profileResponse struct {
// see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"`
// Ads carries the post-move interstitial config for the client's client-mirrored gate: the
// cooldowns (seconds) and whether ads are suppressed in this context (no-ads / no_banner role).
// The client shows a VK interstitial after a confirmed move / hint when not suppressed and the
// cooldown has elapsed. Always present (the client also gates VK-only + online itself).
Ads *adsDTO `json:"ads,omitempty"`
// Email is the account's confirmed email address ("" when none); TelegramLinked and
// VkLinked report whether a platform identity is attached. They drive the profile's
// link / unlink / change-email controls, and are filled outside the pure projection
@@ -73,6 +78,18 @@ type profileResponse struct {
// Filled outside the pure projection (it reads the dictionary registry), so it is empty
// for callers that build the DTO without a Server. See Server.profileResponse.
DictVersions []dictVersion `json:"dict_versions,omitempty"`
// GameLimits carries the caller's tier active-game caps per kind (-1 = unlimited); the client
// counts its active games per kind from the lobby and locks a capped New-Game start. Filled
// outside the pure projection (it reads the limits config). See Server.profileResponse.
GameLimits *gameLimitsDTO `json:"game_limits,omitempty"`
}
// gameLimitsDTO is the caller's tier active-game caps per kind (-1 = unlimited), for the client's
// per-kind New-Game lock. profileResponse.GameLimits carries it.
type gameLimitsDTO struct {
VsAI int `json:"vs_ai"`
Random int `json:"random"`
Friends int `json:"friends"`
}
// dictVersion pairs a game variant's stable label (engine.Variant.String) with its current
@@ -129,7 +146,11 @@ type gameDTO struct {
MultipleWordsPerTurn bool `json:"multiple_words_per_turn"`
// VsAI marks an honest-AI game: the opponent is shown as 🤖 and chat/nudge/add-friend
// are suppressed in the client.
VsAI bool `json:"vs_ai"`
VsAI bool `json:"vs_ai"`
// Kind is the game's origin for the active-game limits (game.Kind): 0 unknown (a pre-existing
// game), 1 vs_ai, 2 random, 3 friends. The lobby counts active games per kind to lock a capped
// New-Game start.
Kind int `json:"kind"`
MoveCount int `json:"move_count"`
EndReason string `json:"end_reason"`
// LastActivityUnix is the lobby sort key: the current turn's start for an active
@@ -172,7 +193,6 @@ type stateDTO struct {
Rack []int `json:"rack"`
BagLen int `json:"bag_len"`
HintsRemaining int `json:"hints_remaining"`
WalletBalance int `json:"wallet_balance"`
// HintUnlockLeftSeconds is the vs_ai idle-hint gate: seconds until the hint unlocks (0 for a human
// game / first move / not your turn). The client anchors a monotonic countdown to it.
HintUnlockLeftSeconds int `json:"hint_unlock_left_seconds"`
@@ -218,13 +238,15 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse {
// profileResponseFor projects an account into its profile DTO.
func profileResponseFor(acc account.Account) profileResponse {
return profileResponse{
UserID: acc.ID.String(),
DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
HintBalance: acc.HintBalance,
UserID: acc.ID.String(),
DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
// The hint balance comes from the payments benefit; profileResponse overrides this
// with the context-aware count. This zero is the fallback when that read fails.
HintBalance: 0,
BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest,
@@ -271,6 +293,7 @@ func gameDTOFromGame(g game.Game) gameDTO {
TurnTimeoutSecs: int(g.TurnTimeout.Seconds()),
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
VsAI: g.VsAI,
Kind: int(g.Kind),
MoveCount: g.MoveCount,
EndReason: g.EndReason,
LastActivityUnix: last.Unix(),
@@ -327,7 +350,6 @@ func stateDTOFrom(v game.StateView, includeAlphabet bool) (stateDTO, error) {
Rack: rack,
BagLen: v.BagLen,
HintsRemaining: v.HintsRemaining,
WalletBalance: v.WalletBalance,
HintUnlockLeftSeconds: v.HintUnlockLeftSeconds,
}
if includeAlphabet {
+7
View File
@@ -72,6 +72,11 @@ func (s *Server) registerRoutes() {
u.GET("/wallet", s.handleWallet)
u.GET("/wallet/catalog", s.handleWalletCatalog)
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
@@ -317,6 +322,8 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "request_declined"
case errors.Is(err, social.ErrFriendCodeInvalid):
return http.StatusUnprocessableEntity, "friend_code_invalid"
case errors.Is(err, social.ErrGuestForbidden), errors.Is(err, lobby.ErrGuestForbidden):
return http.StatusForbidden, "guest_forbidden"
case errors.Is(err, feedback.ErrGuestForbidden):
return http.StatusForbidden, "feedback_guest_forbidden"
case errors.Is(err, feedback.ErrBanned):
@@ -0,0 +1,281 @@
package server
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/payments"
)
// catalogBack is the product-list path the catalog console actions return to.
const catalogBack = "/_gm/catalog"
// atomField pairs a form field with its atom type; priceField pairs a form field with the payment
// method + currency it prices, following the one-currency-per-rail mapping (direct→RUB, vk→VOTE,
// telegram→XTR) plus the value's CHIP price (no method).
var atomFields = []struct{ field, atom string }{
{"chips", "chips"}, {"hints", "hints"}, {"noads", "noads_days"}, {"tournament", "tournament"},
}
var priceFields = []struct {
field, method string
currency payments.Currency
}{
{"price_rub", "direct", payments.CurrencyRUB},
{"price_vote", "vk", payments.CurrencyVote},
{"price_star", "telegram", payments.CurrencyStar},
{"price_chip", "", payments.CurrencyChip},
}
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
// flag, and the inline create form.
func (s *Server) consoleCatalog(c *gin.Context) {
products, err := s.payments.AdminCatalog(c.Request.Context())
if err != nil {
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))
}
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
}
// catalogRow projects a product into its list row.
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
for _, a := range p.Atoms {
row.Atoms = append(row.Atoms, adminconsole.AtomRow{Atom: a.Atom, Quantity: a.Quantity})
}
for _, pr := range p.Prices {
row.Prices = append(row.Prices, adminconsole.PriceRow{Method: pr.Method, Currency: string(pr.Currency), Amount: pr.Amount})
}
return row
}
// consoleCatalogDetail renders one product's edit form, pre-filled from its current composition.
func (s *Server) consoleCatalogDetail(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
products, err := s.payments.AdminCatalog(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
for _, p := range products {
if p.ID == id {
s.renderConsole(c, "product_detail", "catalog", p.Title, productForm(p))
return
}
}
s.renderConsoleMessage(c, "Not found", "no such product", catalogBack)
}
// productForm builds the pre-filled edit form for a product.
func productForm(p payments.AdminProduct) adminconsole.ProductFormView {
fv := adminconsole.ProductFormView{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
for _, a := range p.Atoms {
switch a.Atom {
case "chips":
fv.Chips = a.Quantity
case "hints":
fv.Hints = a.Quantity
case "noads_days":
fv.NoAds = a.Quantity
case "tournament":
fv.Tournament = a.Quantity
}
}
for _, pr := range p.Prices {
switch pr.Currency {
case payments.CurrencyRUB:
fv.PriceRUB = pr.Amount
case payments.CurrencyVote:
fv.PriceVote = pr.Amount
case payments.CurrencyStar:
fv.PriceStar = pr.Amount
case payments.CurrencyChip:
fv.PriceChip = pr.Amount
}
}
return fv
}
// parseProductForm reads a product's title, atoms, prices and active flag from the submitted form.
func parseProductForm(c *gin.Context) (payments.ProductInput, bool) {
in := payments.ProductInput{Title: strings.TrimSpace(c.PostForm("title"))}
for _, a := range atomFields {
if q, err := strconv.Atoi(strings.TrimSpace(c.PostForm(a.field))); err == nil && q > 0 {
in.Atoms = append(in.Atoms, payments.AtomLine{Atom: a.atom, Quantity: q})
}
}
for _, p := range priceFields {
if amt, err := strconv.ParseInt(strings.TrimSpace(c.PostForm(p.field)), 10, 64); err == nil && amt > 0 {
in.Prices = append(in.Prices, payments.PriceLine{Method: p.method, Currency: p.currency, Amount: amt})
}
}
return in, c.PostForm("active") != ""
}
// consoleCreateProduct validates and inserts a new product from the create form.
func (s *Server) consoleCreateProduct(c *gin.Context) {
in, active := parseProductForm(c)
if _, err := s.payments.CreateProduct(c.Request.Context(), in, active); err != nil {
s.renderConsoleMessage(c, "Invalid product", err.Error(), catalogBack)
return
}
s.renderConsoleMessage(c, "Created", "the product was created", catalogBack)
}
// consoleUpdateProduct validates and replaces a product's title, atoms and prices.
func (s *Server) consoleUpdateProduct(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
in, _ := parseProductForm(c)
back := catalogBack + "/" + id.String()
if err := s.payments.UpdateProduct(c.Request.Context(), id, in); err != nil {
s.renderConsoleMessage(c, "Invalid product", err.Error(), back)
return
}
s.renderConsoleMessage(c, "Saved", "the product was updated", back)
}
// consoleArchiveProduct archives or unarchives a product (the desired state rides in the form);
// unarchiving revalidates the sellable shape.
func (s *Server) consoleArchiveProduct(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
active := c.PostForm("active") == "true"
if err := s.payments.SetProductActive(c.Request.Context(), id, active); err != nil {
s.renderConsoleMessage(c, "Cannot change status", err.Error(), catalogBack)
return
}
s.renderConsoleMessage(c, "Updated", "the product status was changed", catalogBack)
}
// consoleDeleteProductAction hard-deletes a never-transacted product; a transacted one is refused
// with a hint to archive instead.
func (s *Server) consoleDeleteProductAction(c *gin.Context) {
id, ok := s.consoleUUID(c, catalogBack)
if !ok {
return
}
err := s.payments.DeleteProduct(c.Request.Context(), id)
if errors.Is(err, payments.ErrProductTransacted) {
s.renderConsoleMessage(c, "Cannot delete", "this product has transactions; archive it instead", catalogBack)
return
}
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Deleted", "the product was deleted", catalogBack)
}
// consoleGrant grants raw benefit atoms (hints / no-ads days / forever) to a chosen origin — a
// zero-price admin sale.
func (s *Server) consoleGrant(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
origin := payments.Source(c.PostForm("origin"))
hints, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("hints")))
noads, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("noads")))
forever := c.PostForm("forever") != ""
if err := s.payments.Grant(c.Request.Context(), id, origin, hints, noads, forever); err != nil {
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
return
}
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", "the benefit was granted", back)
}
// consoleGrantProduct grants a value product's atoms (a reward bundle, possibly archived) to a
// chosen origin. It refuses a product carrying chips or the tournament atom (payments enforces it).
func (s *Server) consoleGrantProduct(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
origin := payments.Source(c.PostForm("origin"))
productID, err := uuid.Parse(strings.TrimSpace(c.PostForm("product_id")))
if err != nil {
s.renderConsoleMessage(c, "Grant failed", "choose a product", back)
return
}
if err := s.payments.GrantProduct(c.Request.Context(), id, origin, productID); err != nil {
s.renderConsoleMessage(c, "Grant failed", err.Error(), back)
return
}
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", "the product was granted", back)
}
// grantForm builds the admin-grant panel: the origin picker and the grantable products (value
// bundles, including archived ones — chips and tournament products are excluded).
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
products, err := s.payments.AdminCatalog(ctx)
if err != nil {
return fv
}
for _, p := range products {
if grantableProduct(p) {
fv.Products = append(fv.Products, adminconsole.GrantProductOption{
ID: p.ID.String(), Title: p.Title, Summary: atomSummary(p.Atoms), Archived: !p.Active,
})
}
}
return fv
}
// grantableProduct reports whether a product can be admin-granted: it carries at least one benefit
// atom (hints / no-ads days) and no chips or tournament atom.
func grantableProduct(p payments.AdminProduct) bool {
benefit := false
for _, a := range p.Atoms {
switch a.Atom {
case "chips", "tournament":
return false
case "hints", "noads_days":
benefit = true
}
}
return benefit
}
// atomSummary renders a product's atoms as "hints×5, noads_days×30".
func atomSummary(atoms []payments.AtomLine) string {
parts := make([]string, 0, len(atoms))
for _, a := range atoms {
parts = append(parts, fmt.Sprintf("%s×%d", a.Atom, a.Quantity))
}
return strings.Join(parts, ", ")
}
@@ -21,6 +21,7 @@ import (
"scrabble/backend/internal/dictadmin"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/social"
@@ -29,10 +30,6 @@ import (
// adminPageSize is the page size of the admin console's paginated lists.
const adminPageSize = 50
// maxHintGrant caps a single operator hint grant. Grants are additive and can never lower a
// wallet, so a fat-fingered grant cannot be undone through this form; the cap bounds one mistake.
const maxHintGrant = 100
// registerConsole mounts the server-rendered admin console under /_gm. The gateway
// puts HTTP Basic-Auth in front of /_gm and reverse-proxies it verbatim; the
// backend trusts the gateway (as for all of /api) and adds only a same-origin guard
@@ -56,12 +53,14 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/users/:id", s.consoleUserDetail)
gm.POST("/users/:id/message", s.consoleUserMessage)
gm.POST("/users/:id/clear-high-rate-flag", s.consoleClearHighRateFlag)
gm.POST("/users/:id/grant-hints", s.consoleGrantHints)
gm.POST("/users/:id/block", s.consoleBlockUser)
gm.POST("/users/:id/unblock", s.consoleUnblockUser)
gm.POST("/users/:id/grant-role", s.consoleGrantRole)
gm.POST("/users/:id/revoke-role", s.consoleRevokeRole)
gm.POST("/users/:id/remove-email", s.consoleRemoveEmail)
gm.POST("/users/:id/grant", s.consoleGrant)
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
gm.POST("/users/:id/refund", s.consoleRefund)
gm.POST("/users/:id/delete", s.consoleDeleteUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
@@ -71,6 +70,10 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/bans/unban", s.consoleUnban)
gm.GET("/games", s.consoleGames)
gm.GET("/games/:id", s.consoleGameDetail)
if s.gamelimits != nil {
gm.GET("/limits", s.consoleLimits)
gm.POST("/limits", s.consoleUpdateLimits)
}
gm.GET("/complaints", s.consoleComplaints)
gm.GET("/complaints/:id", s.consoleComplaintDetail)
gm.POST("/complaints/:id/resolve", s.consoleResolveComplaint)
@@ -106,6 +109,15 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/banner-settings", s.consoleBannerSettings)
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
}
if s.payments != nil {
gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct)
gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
gm.POST("/catalog/:id/delete", s.consoleDeleteProductAction)
gm.GET("/ledger.csv", s.consoleLedgerExport)
}
}
// consoleDashboard renders the landing page: the top-line counts and the resident
@@ -354,7 +366,6 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view := adminconsole.UserDetailView{
ID: acc.ID.String(), DisplayName: acc.DisplayName, Language: acc.PreferredLanguage,
TimeZone: acc.TimeZone, Guest: acc.IsGuest, NotificationsInAppOnly: acc.NotificationsInAppOnly,
PaidAccount: acc.PaidAccount, HintBalance: acc.HintBalance, HintGrantMax: maxHintGrant,
CreatedAt: fmtTime(acc.CreatedAt), HasStats: !acc.IsGuest, ConnectorEnabled: s.connector != nil,
}
if acc.MergedInto != uuid.Nil {
@@ -442,9 +453,41 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
view.Friends = relationRows(rels)
}
}
if s.payments != nil {
if stmt, err := s.payments.AccountStatement(ctx, id); err == nil {
view.Finance = financeView(stmt)
} else {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
}
view.Grant = s.grantForm(ctx)
}
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
}
// financeView projects an account's payments statement into the user-card finance panel, with the
// benefit expiry and ledger times pre-formatted for the logic-free template.
func financeView(stmt payments.Statement) adminconsole.FinanceView {
fv := adminconsole.FinanceView{Present: true, Abuse: stmt.Risk.Abuse, Loss: stmt.Risk.LossChips}
for _, sg := range stmt.Segments {
fv.Segments = append(fv.Segments, adminconsole.SegmentRow{Source: string(sg.Source), Chips: sg.Chips})
}
for _, b := range stmt.Benefits {
row := adminconsole.BenefitRow{Origin: string(b.Origin), Hints: b.Hints, Forever: b.AdsForever}
if !b.AdsPaidUntil.IsZero() {
row.AdsUntil = fmtTime(b.AdsPaidUntil)
}
fv.Benefits = append(fv.Benefits, row)
}
for _, e := range stmt.Ledger {
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
At: fmtTime(e.CreatedAt),
})
}
return fv
}
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
@@ -523,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.
@@ -963,30 +1007,6 @@ func (s *Server) consoleClearHighRateFlag(c *gin.Context) {
s.renderConsoleMessage(c, "Cleared", "high-rate flag cleared", "/_gm/users/"+id.String())
}
// consoleGrantHints adds hints to a user's wallet. The grant is additive (raise-only): it tops a
// player up and can never lower what they already hold, so blocking a reduction is inherent rather
// than a separate guard. A single grant is bounded by maxHintGrant.
func (s *Server) consoleGrantHints(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
n, err := strconv.Atoi(trimForm(c, "amount"))
if err != nil || n < 1 || n > maxHintGrant {
s.renderConsoleMessage(c, "Invalid amount", fmt.Sprintf("enter a whole number of hints to add, between 1 and %d", maxHintGrant), back)
return
}
balance, err := s.accounts.GrantHints(c.Request.Context(), id, n)
if err != nil {
s.consoleError(c, err)
return
}
// A non-empty hint wallet removes the banner: nudge an open client to re-check.
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
}
// consoleRemoveEmail deletes the account's bound email identity (and any pending
// confirmations), freeing the address. It refuses to remove the account's only
// identity, which would leave it unreachable.
@@ -1229,7 +1249,7 @@ func (s *Server) consoleUUID(c *gin.Context, back string) (uuid.UUID, bool) {
// gameRow projects a game summary into its console row.
func gameRow(g game.Game) adminconsole.GameRow {
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI}
return adminconsole.GameRow{ID: g.ID.String(), Variant: g.Variant.String(), Status: g.Status, Players: g.Players, UpdatedAt: fmtTime(g.UpdatedAt), VsAI: g.VsAI, Kind: g.Kind.String()}
}
// trimForm returns the trimmed value of a posted form field.
@@ -0,0 +1,75 @@
package server
import (
"fmt"
"github.com/gin-gonic/gin"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/gamelimits"
)
// gameLimitsDTOFor resolves the caller's tier active-game caps into the profile DTO, so the client
// can lock a capped New-Game start per kind. It returns nil when the limits config is not wired.
func (s *Server) gameLimitsDTOFor(isGuest bool) *gameLimitsDTO {
if s.gamelimits == nil {
return nil
}
l := s.gamelimits.LimitsFor(isGuest)
return &gameLimitsDTO{VsAI: l.VsAI, Random: l.Random, Friends: l.Friends}
}
// consoleLimits renders the per-tier, per-kind active-game limit form (backend.config), read from
// the in-memory cache.
func (s *Server) consoleLimits(c *gin.Context) {
cfg := s.gamelimits.Get()
s.renderConsole(c, "limits", "limits", "Game limits", adminconsole.GameLimitsView{
GuestVsAI: cfg.Guest.VsAI,
GuestRandom: cfg.Guest.Random,
GuestFriends: cfg.Guest.Friends,
DurableVsAI: cfg.Durable.VsAI,
DurableRandom: cfg.Durable.Random,
DurableFriends: cfg.Durable.Friends,
})
}
// consoleUpdateLimits saves the active-game limits and refreshes the hot cache in place. Each
// field is a per-kind cap: -1 is unlimited, 0 blocks the kind, a positive value caps concurrent games.
func (s *Server) consoleUpdateLimits(c *gin.Context) {
cfg := gamelimits.Config{
Guest: gamelimits.Limits{
VsAI: atoiForm(c, "guest_vs_ai"),
Random: atoiForm(c, "guest_random"),
Friends: atoiForm(c, "guest_friends"),
},
Durable: gamelimits.Limits{
VsAI: atoiForm(c, "durable_vs_ai"),
Random: atoiForm(c, "durable_random"),
Friends: atoiForm(c, "durable_friends"),
},
}
if err := validateGameLimits(cfg); err != nil {
s.renderConsoleMessage(c, "Invalid", err.Error(), "/_gm/limits")
return
}
if err := s.gamelimits.Update(c.Request.Context(), cfg); err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Saved", "game limits updated", "/_gm/limits")
}
// validateGameLimits rejects a limit below -1 (the unlimited sentinel); -1, 0 and any positive count
// are valid. It mirrors the backend.config CHECK so a bad value is refused with a clean message
// rather than a raw database error.
func validateGameLimits(cfg gamelimits.Config) error {
for _, v := range []int{
cfg.Guest.VsAI, cfg.Guest.Random, cfg.Guest.Friends,
cfg.Durable.VsAI, cfg.Durable.Random, cfg.Durable.Friends,
} {
if v < gamelimits.Unlimited {
return fmt.Errorf("a limit must be -1 (unlimited), 0, or a positive count")
}
}
return nil
}
@@ -0,0 +1,71 @@
package server
import (
"encoding/csv"
"fmt"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// consoleRefund refunds a paid order in full at the operator's request. The operator performs the
// actual money refund on the rail (Robokassa cabinet / VK support / Telegram refundStarPayment);
// this records it — a refund ledger row and a floor-0 chip revoke (never negative, D27). Idempotent.
func (s *Server) consoleRefund(c *gin.Context) {
id, ok := s.consoleUUID(c, "/_gm/users")
if !ok {
return
}
back := "/_gm/users/" + id.String()
if s.payments == nil {
s.renderConsoleMessage(c, "Unavailable", "payments are not enabled", back)
return
}
orderID, err := uuid.Parse(strings.TrimSpace(c.PostForm("order_id")))
if err != nil {
s.renderConsoleMessage(c, "Refund failed", "no order to refund", back)
return
}
out, err := s.payments.RefundOrderFull(c.Request.Context(), orderID)
if err != nil {
s.renderConsoleMessage(c, "Refund failed", err.Error(), back)
return
}
if out.AlreadyRefunded {
s.renderConsoleMessage(c, "Already refunded", "this order was already refunded", back)
return
}
s.publishBannerChange(id)
msg := fmt.Sprintf("revoked %d chips", out.Revoked)
if out.Loss > 0 {
msg += fmt.Sprintf("; %d chips were already spent (recorded as a loss + abuse flag)", out.Loss)
}
s.renderConsoleMessage(c, "Refunded", msg, back)
}
// consoleLedgerExport streams the entire append-only ledger as a CSV attachment for tax reporting
// and rail reconciliation. The snapshot column carries the raw purchase/refund JSON.
func (s *Server) consoleLedgerExport(c *gin.Context) {
rows, err := s.payments.LedgerExport(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", `attachment; filename="ledger.csv"`)
w := csv.NewWriter(c.Writer)
_ = w.Write([]string{
"created_at", "account_id", "kind", "source", "origin", "chips_delta",
"product_id", "order_id", "provider", "provider_payment_id", "snapshot",
})
for _, r := range rows {
_ = w.Write([]string{
r.CreatedAt.UTC().Format(time.RFC3339), r.AccountID, r.Kind, r.Source, r.Origin,
strconv.Itoa(r.ChipsDelta), r.ProductID, r.OrderID, r.Provider, r.ProviderPaymentID, r.Snapshot,
})
}
w.Flush()
}
+14 -22
View File
@@ -10,6 +10,7 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
)
// The handlers below cover the game and chat operations the UI needs. They follow
@@ -46,10 +47,11 @@ type historyDTO struct {
}
// gameListDTO is the caller's games (active and finished) for the lobby. AtGameLimit
// reports whether the caller has reached the simultaneous quick-game cap
// (game.MaxActiveQuickGames); while it is true the lobby disables "New Game" and shows a
// notice. It rides the lobby response — which the lobby re-fetches on every game event —
// instead of a separate request.
// reports whether the caller has reached its tier's active-game cap for the random
// (quick auto-match) kind (the per-tier, per-kind limits in backend.config); while
// it is true the lobby disables "New Game" and shows a notice. It rides the lobby
// response — which the lobby re-fetches on every game event — instead of a separate
// request.
type gameListDTO struct {
Games []gameDTO `json:"games"`
AtGameLimit bool `json:"at_game_limit"`
@@ -395,23 +397,13 @@ func (s *Server) handleSaveDraft(c *gin.Context) {
c.JSON(http.StatusOK, okResponse{OK: true})
}
// atGameLimit reports whether uid already holds the maximum number of simultaneous
// quick games (game.MaxActiveQuickGames). It backs both the lobby's at_game_limit flag
// and the new-game gate; friend games created by invitation are not counted.
func (s *Server) atGameLimit(ctx context.Context, uid uuid.UUID) (bool, error) {
n, err := s.games.CountActiveQuickGames(ctx, uid)
if err != nil {
return false, err
}
return n >= game.MaxActiveQuickGames, nil
}
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid is at the
// simultaneous quick-game cap, and reports whether the caller may proceed. It guards
// every new-game entry point — quick auto-match/AI and invitation creation; accepting an
// incoming invitation is deliberately exempt.
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID) bool {
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
// ensureUnderGameLimit aborts the request with 409 game_limit_reached when uid has reached its
// tier's active-game cap for kind (the per-tier, per-kind limits in backend.config), and reports
// whether the caller may proceed. It guards the quick new-game entry points — auto-match (random)
// and AI (vs_ai). Friend-invitation limits are enforced in the lobby's CreateInvitation, and
// accepting an incoming invitation is deliberately exempt.
func (s *Server) ensureUnderGameLimit(c *gin.Context, uid uuid.UUID, kind gamelimits.Kind) bool {
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, kind)
if err != nil {
s.abortErr(c, err)
return false
@@ -437,7 +429,7 @@ func (s *Server) handleListGames(c *gin.Context) {
s.abortErr(c, err)
return
}
atLimit, err := s.atGameLimit(c.Request.Context(), uid)
atLimit, err := s.games.AtGameLimit(c.Request.Context(), uid, gamelimits.KindRandom)
if err != nil {
s.abortErr(c, err)
return
@@ -133,9 +133,6 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
}
inviteeIDs = append(inviteeIDs, id)
}
if !s.ensureUnderGameLimit(c, uid) {
return
}
inv, err := s.invitations.CreateInvitation(c.Request.Context(), uid, inviteeIDs, settings)
if err != nil {
s.abortErr(c, err)
+6 -3
View File
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/gamelimits"
)
// The /api/v1/user/* endpoints require X-User-ID (RequireUserID middleware). The
@@ -189,13 +190,15 @@ func (s *Server) handleEnqueue(c *gin.Context) {
if !s.ensureVariantAllowed(c, uid, variant.String()) {
return
}
if !s.ensureUnderGameLimit(c, uid) {
return
}
kind := gamelimits.KindRandom
enter := s.matchmaker.Enqueue
if req.VsAI {
kind = gamelimits.KindVsAI
enter = s.matchmaker.StartVsAI
}
if !s.ensureUnderGameLimit(c, uid, kind) {
return
}
res, err := enter(c.Request.Context(), uid, variant, req.MultipleWordsPerTurn)
if err != nil {
s.abortErr(c, err)
+81 -2
View File
@@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
// walletDTO is the user-facing wallet: the context-visible chip segments and the
// context-applicable benefits (the no-ads term or forever flag, and the available hints).
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
// only when it is positive.
type walletDTO struct {
Segments []walletSegmentDTO `json:"segments"`
AdsForever bool `json:"ads_forever"`
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
Hints int `json:"hints"`
RewardChips int `json:"reward_chips"`
}
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
@@ -107,8 +112,25 @@ 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.
// 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.
func (s *Server) handleWallet(c *gin.Context) {
uid, ok := userID(c)
if !ok {
@@ -125,7 +147,13 @@ func (s *Server) handleWallet(c *gin.Context) {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletDTOFrom(view))
dto := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
} else {
dto.RewardChips = payout
}
c.JSON(http.StatusOK, dto)
}
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
@@ -162,3 +190,54 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
}
c.JSON(http.StatusOK, walletDTOFrom(view))
}
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce, the idempotency
// key for a single watched view (a retry credits once).
type walletRewardRequest struct {
Nonce string `json:"nonce"`
}
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
// updated wallet (like a spend).
func (s *Server) handleWalletReward(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletRewardRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
abortBadRequest(c, "nonce is required")
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
if err != nil {
s.abortErr(c, err)
return
}
if outcome.Capped {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
return
}
if outcome.Chips == 0 && !outcome.AlreadyCredited {
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
return
}
view, err := s.payments.Wallet(ctx, uid, cxt, present)
if err != nil {
s.abortErr(c, err)
return
}
view2 := walletDTOFrom(view)
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
view2.RewardChips = payout
}
c.JSON(http.StatusOK, view2)
}
+7
View File
@@ -25,6 +25,7 @@ import (
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/gamelimits"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
@@ -100,6 +101,10 @@ type Deps struct {
// console routes are registered when the wallet surface lands. A nil Payments
// omits them.
Payments *payments.Service
// GameLimits is the per-tier, per-kind active-game limit config, cached in memory. The
// game domain reads it through game.Service (SetGameLimits) for the new-game gate; the admin
// console reads and edits it here. A nil GameLimits omits the limits console section.
GameLimits *gamelimits.Service
// Notifier publishes live-event intents — here the banner-eligibility re-poll
// signal the banner/hint/role console actions emit. A nil Notifier discards
// them (notify.Nop).
@@ -140,6 +145,7 @@ type Server struct {
banview *banview.View
ads *ads.Service
payments *payments.Service
gamelimits *gamelimits.Service
robokassa robokassa.Config
notifier notify.Publisher
console *adminconsole.Renderer
@@ -194,6 +200,7 @@ func New(addr string, deps Deps) *Server {
banview: deps.BanView,
ads: deps.Ads,
payments: deps.Payments,
gamelimits: deps.GameLimits,
robokassa: deps.Robokassa,
notifier: notifier,
renderer: deps.Renderer,
+6
View File
@@ -65,6 +65,12 @@ func (svc *Service) IssueFriendCode(ctx context.Context, accountID uuid.UUID) (F
// ErrRequestBlocked (a block stands between the pair). A redeem bypasses any prior
// decline between the two: it clears the old row and writes a fresh friendship.
func (svc *Service) RedeemFriendCode(ctx context.Context, redeemerID uuid.UUID, code string) (uuid.UUID, error) {
// A guest cannot use friends: a durable-account feature (the UI hides it).
if acc, err := svc.accounts.GetByID(ctx, redeemerID); err != nil {
return uuid.UUID{}, err
} else if acc.IsGuest {
return uuid.UUID{}, ErrGuestForbidden
}
issuerID, codeID, err := svc.store.liveFriendCodeByHash(ctx, hashFriendCode(code), svc.now())
if err != nil {
return uuid.UUID{}, err
+7
View File
@@ -51,6 +51,13 @@ func (svc *Service) SendFriendRequest(ctx context.Context, requesterID, addresse
if requesterID == addresseeID {
return ErrSelfRelation
}
// A guest cannot use friends — friends are a durable-account feature; the UI hides the
// flow, this is the server source of truth.
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
return err
} else if acc.IsGuest {
return ErrGuestForbidden
}
iBlockThem, err := svc.store.blockExists(ctx, requesterID, addresseeID)
if err != nil {
return err
+6
View File
@@ -42,6 +42,12 @@ func (svc *Service) RequestInGame(ctx context.Context, requesterID, addresseeID,
if requesterID == addresseeID {
return ErrSelfRelation
}
// A guest cannot use friends: a durable-account feature (the UI hides it).
if acc, err := svc.accounts.GetByID(ctx, requesterID); err != nil {
return err
} else if acc.IsGuest {
return ErrGuestForbidden
}
isRobot, err := svc.accounts.IsRobot(ctx, addresseeID)
if err != nil {
return err
+3
View File
@@ -59,6 +59,9 @@ var (
// ErrRequestBlocked is returned when the addressee does not accept friend
// requests (their global toggle) or a block stands between the two accounts.
ErrRequestBlocked = errors.New("social: the addressee is not accepting friend requests")
// ErrGuestForbidden is returned when a guest attempts a durable-only action (send a friend
// request, redeem a friend code); friends are a durable-account feature.
ErrGuestForbidden = errors.New("social: guests cannot use friends")
// ErrRequestNotFound is returned when no pending friend request matches.
ErrRequestNotFound = errors.New("social: no pending friend request")
// ErrNoSharedGame is returned when a friend request targets someone the
+18
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).
@@ -125,6 +132,17 @@ GATEWAY_VK_APP_SECRET=
# come from VITE_VK_APP_ID / VITE_VK_ID_REDIRECT_URL above. All three empty disables link.vk.*.
GATEWAY_VK_ID_CLIENT_SECRET=
# --- Payments: Robokassa (direct RUB rail) ----------------------------------
# The shop's merchant login + the two pass phrases (Password1 signs the launch request,
# Password2 signs/verifies the Result callback). An empty login leaves the direct rail off.
# ROBOKASSA_TEST=1 runs test payments against the shop's TEST pass phrases (no real money);
# empty/0 is live. Mapped in compose to BACKEND_ROBOKASSA_*; Gitea TEST_/PROD_ secrets, with
# PROD_BACKEND_ROBOKASSA_TEST a variable so go-live is a flag flip, not a secret redeploy.
ROBOKASSA_MERCHANT_LOGIN=
ROBOKASSA_PASSWORD1=
ROBOKASSA_PASSWORD2=
ROBOKASSA_TEST=
# --- Gateway anti-abuse ------------------------------------------------------
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
# where the IP ban is on (prod), logs + a ban metric otherwise (test). Plant the value
+38 -2
View File
@@ -76,6 +76,9 @@ compose binds from this directory.
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | derived | The Mini App URL the bot hands out in deep links / buttons. The deploy derives `PUBLIC_BASE_URL + /telegram/`; set it directly only for a local run (compose still `:?`-requires it). |
| `EXPORT_SIGN_KEY` | secret | HMAC key signing the public finished-game export download URLs (`/dl/*`). Generate with `openssl rand -base64 32`. |
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
@@ -112,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). |
@@ -230,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**
@@ -252,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
+15 -1
View File
@@ -213,6 +213,20 @@
loop:
- ""
- config
- certs
- dumps
- images
# The certs dir holds the reverse-mTLS bot-link keypair, bind-mounted into the gateway (and
# backend) which run as the distroless nonroot UID 65532 — not the deploy user. The dir must be
# traversable by "other" (0755) or the nonroot process cannot open the 0644 keypair and crash-loops
# at startup ("mtls: load server keypair: ... permission denied") — a latent failure that only bites
# on a container restart (e.g. a host reboot), not while a long-running container holds the keypair
# in memory. The keys themselves are 0644 by design (see the gateway compose); the host is
# single-tenant and SSH-access-controlled, so a traversable certs dir adds no meaningful exposure.
- name: Create the scrabble certs directory (traversable by the nonroot gateway UID)
ansible.builtin.file:
path: "{{ scrabble_base_dir }}/certs"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: "0755"
+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"
+18
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
@@ -207,6 +210,9 @@ services:
VITE_VK_ID_REDIRECT_URL: ${VITE_VK_ID_REDIRECT_URL:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
# The rewarded-ad test stub (1 = a toast instead of a real ad; the test contour only, empty
# elsewhere so production shows real ads).
VITE_ADS_STUB: ${VITE_ADS_STUB:-}
# Go binary version (the SPA's VITE_APP_VERSION is the same git tag).
VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
@@ -216,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
@@ -248,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.
+11
View File
@@ -49,9 +49,15 @@ 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'
export ROBOKASSA_MERCHANT_LOGIN='$ROBOKASSA_MERCHANT_LOGIN'
export ROBOKASSA_PASSWORD1='$ROBOKASSA_PASSWORD1'
export ROBOKASSA_PASSWORD2='$ROBOKASSA_PASSWORD2'
export ROBOKASSA_TEST='$ROBOKASSA_TEST'
export VITE_VK_APP_ID='$VITE_VK_APP_ID'
export VITE_VK_ID_REDIRECT_URL='$VITE_VK_ID_REDIRECT_URL'
export GATEWAY_VK_ID_CLIENT_SECRET='$GATEWAY_VK_ID_CLIENT_SECRET'
@@ -73,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
+186 -54
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
@@ -650,18 +712,30 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
game is `open` the starter may move on their turn, but resign, chat and nudge are
refused (no opponent yet) and the lobby and opponent card show a "searching for
opponent" placeholder.
- **Simultaneous-game cap**: a player may hold at most `game.MaxActiveQuickGames`
(**10**) active quick games. `game.Service.CountActiveQuickGames` counts the games
seating the account in status `active` or `open` **without** a linked
`game_invitations` row — friend games are excluded, and hidden games still occupy a
slot, so it is a dedicated count rather than a filter over the lobby list. The backend
**gate** (`Server.ensureUnderGameLimit`) refuses **both** new-game entry points at the
cap — `POST /lobby/enqueue` and `POST /invitations` — with **409 `game_limit_reached`**;
**accepting** an invitation (`POST /invitations/:id/accept`) is never gated, so friend
games are capped only at initiation. The lobby learns the state from a boolean
**`at_game_limit`** carried on the `games.list` response — the lobby already re-fetches
that on entry and on every game event, so the flag needs no separate request or
per-event payload; while it is set the client disables **New Game** and shows a notice.
- **Active-game caps (per tier, per kind)**: a player's simultaneous unfinished games are
capped per **kind**`vs_ai`, `random` (quick auto-match), `friends` — with independent
**guest** and **durable-account** tiers. The caps live in the single-row `backend.config`
table (`-1` = unlimited), read once at boot into an in-memory cache (`internal/gamelimits`)
and refreshed in place when an operator edits them in the admin (`/_gm/limits`) — so a
login or game-create never queries the table. The seeded defaults are guest **1 vs_ai / 1
random / 0 friends** and durable **10 / 10 / 10** (this replaced the earlier flat
`MaxActiveQuickGames`=10 combined cap). Each game is tagged with `games.game_kind` on
creation (0 = an untagged game, never gated). `game.Service.AtGameLimit` resolves the
account's tier, then counts its `active`/`open` games of the kind (hidden games still
occupy a slot) against the cap. The gate (`Server.ensureUnderGameLimit`) refuses
`POST /lobby/enqueue` — random or vs_ai per the request — with **409 `game_limit_reached`**;
the `POST /invitations` (friends) path enforces the durable friends cap the same way and
refuses a **guest** outright with **403** (guests cannot use friends — the same guest gate
covers friend requests and friend-code redemption). **Accepting** an invitation
(`POST /invitations/:id/accept`) is never gated, so friend games are capped only at
initiation. The client counts its lobby games per kind against the per-tier caps it reads on
the profile (`Profile.game_limits`, carrying `GameView.kind`), and locks a capped kind's
New-Game start — a 🔒 outline button that opens a prompt instead of a game: a sign-in funnel
for a guest, a "finish a current game first" notice for a durable account (native inside
Telegram, an in-app modal elsewhere), lifting when the profile is re-fetched after a
guest→durable upgrade. The `games.list` `at_game_limit` boolean (now the random-kind cap) is
still carried but no longer drives the UI — the per-kind start lock superseded the old
New-Game-tab disable.
- **Friends**: two add paths over one `friendships` table. A **one-time
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
@@ -893,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
@@ -983,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
@@ -1041,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
@@ -1158,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
@@ -1272,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
@@ -1306,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
@@ -1407,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

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