4cac09c9f3
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 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.
The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.
The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.
Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.
The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.
Decisions D48 and D49 revised.
23 KiB
23 KiB
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/jetgenregenerates 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 asnumeric(float precision loss) — use bigint minor units + aMoneytype; store durations as int seconds, notinterval. go mod tidychokes on dot-free local module paths (scrabble/...). Hand-editgo.modwhen a bump is needed. The solver (../scrabble-solver) is consumed viago.workreplace 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. - The UI job's last step is a size gate, not a test:
cd ui && node scripts/bundle-size.mjs(gzip budgets per entry,BUDGETin that file).pnpm buildsucceeding says nothing about it, and the app entry usually sits within a few hundred bytes of its cap, so any feature touching an always-loaded screen can fail CI green-on-everything-else. Run it, and rebuild first — the script measures whatever is indist/, so a stale build silently measures the wrong branch. Put new logic behind an existing lazy import where it belongs; raisingBUDGETis allowed but the file's header comment records the reason for every past raise — keep that up, and ask the owner. - CI runner shares this host's
/tmpas 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 checkoccasionally 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 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/androidcompiles atVERSION_21; a JDK 17 Gradle run dies witherror: invalid source release: 21. Install sudo-free via the Homebrew formulabrew install openjdk@21(thetemurin@21cask wants sudo for a system.pkg, unusable non-interactively) + a user symlink into~/Library/Java/JavaVirtualMachines/so/usr/libexec/java_home -v 21finds it. JDK 17 stays fine forsdkmanager/avdmanager. - Cap 8 SDK pins: compileSdk/targetSdk 36, minSdk 24, Gradle 8.14.3, AGP 8.13.0
(
ui/android/variables.gradle). Installplatforms;android-36— Android Studio's newerandroid-36.1does NOT satisfy compileSdk 36. - SDK is Android Studio's at
~/Library/Android/sdk(nocmdline-toolsby default →brew install --cask android-commandlinetools, thensdkmanager/avdmanager --sdk_root=$HOME/Library/Android/sdk). Gradle finds it viaANDROID_HOME(local.propertiesis gitignored, machine-specific). - Build recipe:
cd ui && pnpm build && node_modules/.bin/cap sync android; thencd 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 localui/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 needssleep→ run it as a background Bash task (foregroundsleepis blocked). Browsers/WebView are cached, so a full offline vs_ai turn is drivable viaadb 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 theenv()=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 Playwrightchromium.connectOverCDP('http://localhost:9222')→page.evaluate(read each element'sgetBoundingClientRect().top, and--safe-area-inset-*vsenv(...)). sharpis whitelisted inui/pnpm-workspace.yaml(allowBuilds: sharp: true) —@capacitor/assetsuses it forpnpm android:assets(launcher icon/splash); else pnpm 11 raisesERR_PNPM_IGNORED_BUILDS.- Bundled offline dicts come from the
scrabble-dictionaryrelease (scrabble-dawg-<DICT_VERSION>.tar.gz, keyed on theDICT_VERSIONGitea var — the same source the backend image + CIcurl), NOTscrabble-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
0the 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.Servicelive events and the server REST DTOs. Change both or they drift. - The client codec UPPER-CASES history words (
lib/codec.tsdecodeMove) for display, while the engine/evaluator decodes candidate words to lower. Anything comparing the two must case-fold, or it silently never matches — this is how the Erudit no-repeat rule shipped dead on the online path while working offline (offline compares alphabet-index keys, so it has no case at all). A test that hand-builds such a set instead of deriving it the way the screen does will pass vacuously. - 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
$statevariablestate.svelte-checkthen misreads$stateas 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/.ghostbutton classes. Style buttons per-component with scoped CSS + design tokens (mirrorNewGame's.invite). - A
$stateproxy 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
showPopupeats the user-activation. Share / clipboard called from inside ashowPopupcallback fail (no gesture). Use your ownModalfor 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.shareand ignore<a download>. Deliver a client-generated file by copying it to the clipboard. - VK Android WebView ignores
target=_blank. Open external links throughlib/links.ts(routes tovk.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-jspolyfill loads only on old engines, andindex.htmlhas a boot gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also avminglyph 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 gameG1hard-codes one; flipG1'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 testrunner can't fetch browsers in this sandbox —playwright installdies withEBADFagainst the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in CI (theuijob installs chromium+webkit), or drive a state live through the Playwright MCP browser against a localvite --mode mockserver for visual verification. But if chromium/webkit are already cached in~/Library/Caches/ms-playwright/,playwright testruns 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/dawgvia the webServer'sbundle-dicts.mjsfallback. - A native (Capacitor) e2e must inject
window.androidBridge, NOTwindow.Capacitor.getPlatform.@capacitor/core(pulled in during boot byinitNativeShell's dynamic@capacitor/appimport) replaces any pre-setwindow.Capacitorwith its own shim and derives the platform fromwindow.androidBridge(android) /window.webkit.messageHandlers(ios) — so a bare injectedCapacitor.getPlatform: () => 'android'is clobbered toweband the boot falls to/login. Injectwindow.androidBridge = { postMessage(){} }in anaddInitScript(seee2e/native.spec.tssimulateNative);initNativeShellis written to tolerate the stub bridge (try/catchround the@capacitor/appaddListener). 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-sidecurlto 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 inPRERELEASE.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_codeseed. Clear client prefs too when testing locale. DNS=inTEST_AWG_CONFpins 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-recreatefor caddy; don't trust "deploy green" for an edge-config change. - A new gateway edge route MUST be added to the Caddyfile
@gatewaymatcher 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: h3with no UDP/443 open; clients cache it ~30 days. Fix withAlt-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,
/_gmexempt, 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/dictionaryupload, 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. teaCLI for all Gitea ops (PRs, secrets, variables, dispatch).ghdoes 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; itsALL GREENalready covers the gated deploy job. Pass--no-runs 600when 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_zoneisNOT 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:00–07: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 aliasesscrabble-main-ops/scrabble-tg-ops. Deploy is manual dispatch only, rolling + health-gated + auto-rollback. Hosts are provisioned bydeploy/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 (E0–E9). go-jet money rule above applies.- YooKassa (the direct rail since v1.x) does NOT sign its webhooks. Authenticity is the
confirming
GET /v3/payments/{id}— never the notification body. Two extra guards ride on the confirmed object: itsmetadata.order_idmust name the order, and itstestflag must match the shop'sIsTest(a test-shop payment must never credit real chips). Answer 200 for anything durably decided, 5xx only to be redelivered (YooKassa retries 24h). - Do NOT gate the notification on the sender IP — tried, reverted. The contour sits behind a
tunnel and sees every caller as an internal address (
10.77.0.1), so the allowlist rejected every genuine notification and the credit fell back to the reconcile sweep. Same root cause as the IP bans being prod-only. It bought nothing anyway: the order is resolved from the notification metadata before any provider call, so a forged id costs one indexed read, and guessing a live order id means guessing a uuid. - Never key a re-check interval off the order TTL. The order lifetime answers "how long may a
customer take to pay"; the re-check answers "how soon do we notice a lost callback". Tying them
together made a failed notification cost the customer the full 30-minute TTL before the chips
landed.
payments.ReconcileAfteris its own constant for that reason. - Subscribe to
refund.succeeded, not just the payment events. The YooKassa cabinet lets an operator refund without touching our API, so without that event the money goes back while the chips stay credited. The reversal engine is full-refund-only (it rejects any amount other than the order's), so a partial refund must record nothing and be logged for a human — there is no non-arbitrary chip cost for a part-refund. Also: a refund is only recorded in statussucceeded; apendingone can still be canceled and the ledger is append-only. - YooKassa DOES have a test mode — a separate test shop (own shopId/secret, test cards, up to 20 of them), and webhooks/receipts/refunds all work there. The whole rail is verifiable on the contour without real money; don't plan around "no test payments".
- We send NO receipt: the merchant is on НПД, which is outside 54-ФЗ, and YooKassa does not
serve receipts for that regime (checked with their support). Reporting goes to «Мой налог».
The fiscal code is kept dormant behind
BACKEND_YOOKASSA_VAT_CODE— unset = send nothing, a 54-ФЗ rate code turns «Чеки от ЮKassa» back on (the НПД income ceiling makes that foreseeable). Gotcha if you ever switch it on:payment_subject/payment_mode/vat_codeare fields insidereceipt.items[](54-ФЗ tags 1212/1214/1199), documented under the receipts section, not the payment API — easy to miss; and a malformed receipt is an API error at payment creation, so it breaks purchases rather than silently skipping the fiscal document. - The gateway cannot import
backend/internal/*(separate module + Go's internal rule), so a security list shared by both hops either lives inpkg/or is enforced backend-side. The YooKassa sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP asX-Forwarded-For— do not duplicate the CIDR list into the gateway. - Robokassa is retired but wired. The direct rail resolves YooKassa → else Robokassa, and no
deployment sets the Robokassa credentials. Reviving it is a credentials change, not a code change;
backend/internal/robokassa/README.mdholds the retired vars, cabinet URLs and the known gap (the per-channel…_WEB_*/…_ANDROID_*vars never reached any workflow). - VK payment title trap: a product title with an HTML-special char (
& < > " ') silently kills VK purchases. VK HTML-escapes it in theorder_status_changecallback echo ("→") but signs a different form → the gateway logsvk callback: bad signatureand rejects it (get_item passes — its request carries no title).validateProductnow forbids those chars; keep titles on « » guillemets. Diagnose a raw VK callback body by tcpdumping the plaintext caddy→gateway hop (tcp port 8081, the h2c DATA frame is unencrypted); VK retriesorder_status_changefor ~30 min.
- YooKassa (the direct rail since v1.x) does NOT sign its webhooks. Authenticity is the
confirming
- PITR — pgBackRest → Selectel S3 (encrypted, 30-day), currently gated off; runbook in
deploy/README.md. Gotcha: run pgBackRest via docker-exec as thepostgresuser 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.
- 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 — Capacitor bundle model, client-version gate, offline-first, RuStore; the
design lives in
docs/ARCHITECTURE.md(§2 gate, §3 identity, §13 native build).