e3961fe4ca
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) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Successful in 2m28s
Refunding from /_gm reported "Already refunded" for a refund that had just succeeded, which reads as "you clicked twice". The two recording paths race. YooKassa fires refund.succeeded the moment POST /v3/refunds returns, so the notification handler often writes the reversal before the console's own write lands. Both name the same refund id, so the ledger's idempotency index rejects the second — which is the mechanism working exactly as intended: on the contour the money moved once, one refund row was written, the balance is right and no abuse flag was raised. Only the message was wrong about why. The console now reports success on that path, and the notification's log line no longer claims the refund was "issued outside the console" when it may well have come from it. Covered by an integration test that lands the notification first and then refunds from the console, asserting the operator is told it succeeded and that exactly one refund row exists.
306 lines
24 KiB
Markdown
306 lines
24 KiB
Markdown
# 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.
|
||
- **The UI job's last step is a size gate, not a test:** `cd ui && node scripts/bundle-size.mjs`
|
||
(gzip budgets per entry, `BUDGET` in that file). `pnpm build` succeeding 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 in `dist/`, so a stale build silently measures the wrong branch. Put
|
||
new logic behind an existing lazy import where it belongs; raising `BUDGET` is 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 `/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 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.
|
||
- **The client codec UPPER-CASES history words** (`lib/codec.ts` `decodeMove`) 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 `$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: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 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 (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: its `metadata.order_id` must name the order, and its `test` flag must match the
|
||
shop's `IsTest` (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.
|
||
- **The console refund and the `refund.succeeded` notification race, benignly.** YooKassa fires the
|
||
event the moment `POST /v3/refunds` returns, so the notification often records the reversal before
|
||
the console's own write lands. Both name the same refund id, so the ledger's idempotency index
|
||
catches the second and nothing is revoked twice — but the console must not then report "already
|
||
refunded", which reads as "you clicked twice" for a refund that just succeeded.
|
||
- **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.ReconcileAfter` is 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 status `succeeded`;
|
||
a `pending` one 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_code` are fields *inside*
|
||
`receipt.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 in `pkg/` or is enforced backend-side. The YooKassa
|
||
sender allowlist is enforced in the backend, with the gateway forwarding the real peer IP as
|
||
`X-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.md` holds 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 the `order_status_change` callback echo (`"`→`"`) but
|
||
signs a different form → the gateway logs `vk callback: bad signature` and rejects it (get_item
|
||
passes — its request carries no title). `validateProduct` now 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 retries `order_status_change` for ~30 min.
|
||
- **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.
|
||
- **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).
|