Compare commits

..

24 Commits

Author SHA1 Message Date
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 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 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 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
173 changed files with 265 additions and 7848 deletions
-211
View File
@@ -1,211 +0,0 @@
# 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).
- **`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.
- **`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).
+7 -12
View File
@@ -510,20 +510,15 @@ jobs:
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /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.
# /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.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
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"
if echo "$out" | grep -q "290210610742"; then
echo "ok: /offer/ serves the public offer page"
else
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
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
docker logs --tail 50 scrabble-landing || true
exit 1
fi
-5
View File
@@ -115,11 +115,6 @@ jobs:
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes): one account for
-4
View File
@@ -70,10 +70,6 @@ jobs:
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
-680
View File
@@ -1,680 +0,0 @@
# Native Android (Capacitor) — RuStore MVP: bundle + client-version gate + offline-first
> Self-contained implementation plan for the standalone Android app, to be executed in a fresh
> session (another device). Work through the breakdown AG in order; each part states its own
> "Done when". `PLAN.md` at the repo root is the precedent for a top-level plan doc here.
---
## Context (why)
`erudit-game.ru` ships today as a web SPA (Svelte + Vite) served by the gateway, plus Telegram and
VK Mini Apps. The owner wants a **standalone Android app**, first on **RuStore**. The stack already
declares Capacitor as the target and is pre-seamed for it (feature-detected `window.Capacitor`,
`file://`-safe hash router, relative asset base, a reserved native share branch,
`viewport-fit=cover`/safe-area layout, a build-time gateway origin var), but **nothing is
scaffolded** — no `@capacitor/*` dep, no `capacitor.config.*`, no `android/`.
Two problems native introduces that the web never had:
1. **An installed build can be arbitrarily old.** On web every load fetches the current client, so
client and server are always paired. In a store a user may run a months-old bundle; if the
FlatBuffers wire schema changes incompatibly, an old bundle cannot speak to the server at all.
Today there is **no** client↔server version contract — the only "update" path is the web-only
`location.reload()`, useless to a bundled APK. → build a **minimum-supported-client gate**.
2. **First launch must work with no network.** The app must open straight into a guest experience
and let the user play locally (vs_ai *and* 2-4-player pass-and-play / hotseat) even if the
internet is off on first launch. The current offline mode is a *returning-user* feature: it needs
a cached session/profile and server-fetched dictionaries (`offline.ts shouldBootOffline` requires
`hasSession && hasProfile`; dicts come from `gateway.fetchDict`). → build **offline-first**:
bundle dictionaries in the APK and boot as a device-local guest with no server session.
**Intended outcome:** a signed APK on RuStore that (a) loads the packaged SPA against the production
gateway, (b) is protected by the version gate so future incompatible server changes turn old
installs away cleanly, and (c) opens offline-first as a soft guest with local play.
## Locked decisions (owner interview)
| Decision | Choice |
|---|---|
| First store | **RuStore** (Google Play is a later variant — see below) |
| Update model | **Bundle** (`dist/` inside the APK) **+ a client-version gate** (no OTA, no remote-URL wrapper) |
| First-launch offline | **Offline-first in the MVP**: enter as a local guest, play vs_ai + hotseat with zero network |
| Login surface | Guest + email only (already the case — `Login.svelte` shows only these; VK/Telegram auth runs only inside their Mini Apps) |
| Payments in MVP | Hidden (deferred; reuse the distribution flag) |
| appId (permanent) | **`ru.eruditgame.app`** |
| App display name | **`Эрудит`** (Cyrillic) |
| Toolchain (locked) | **Capacitor 8** (`@capacitor/*` `^8`); **compileSdk/targetSdk 36, minSdk 24**; **JDK 21** (required — `@capacitor/android` compiles at Java 21; AGP 8.13 / Gradle 8.14.3); Android SDK `platforms;android-36` + `build-tools;36.x` |
**Conventions:** all code/comments/commits/docs in English. Do NOT put stage/phase numbers in code,
commits, or PR titles. Bake design into the main docs (`docs/ARCHITECTURE.md` etc.) — this repo
rejects standalone spec artifacts, so `ANDROID_PLAN.md` is the only new plan file; do not create
`docs/superpowers/specs/*`. Branch model: `feature/android-native` from `development`, PR into
`development` (contour review), then promote `development → master`, tag, dispatch the Android build.
The gate change is **wire-additive and contour-safe** (a new HTTP header + a new envelope
`result_code` string; no FBS/proto regen, no schema migration). The offline-first change is
client-only (no server change).
---
## Progress (as-built)
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
`ui/src/lib/native.ts` wired from `App.svelte`. `pnpm build``cap sync``./gradlew assembleDebug`
builds a debug APK that installs and **cold-launches to the Login screen** on the Pixel_10 emulator.
`svelte-check` 0 errors, `vitest` 584 passed. Build recipe + toolchain gotchas live in
`.claude/CLAUDE.md` → "Native Android build (Capacitor)".
**Launcher icon:** a **temporary** placeholder is in place — `ui/assets/icon.png` (the maskable «Э»
brand mark upscaled 512→1024) → `pnpm android:assets` regenerated the Android launcher/adaptive
icons + splashes. Superseded by the icon rebrand below.
- **B. Native web-build correctness — ✅ DONE & verified (2026-07-12).** New `ui/src/lib/origin.ts`
`gatewayOrigin()`; the two `location.origin` landmines fixed (`game/Game.svelte` export/share URL,
`screens/Wallet.svelte` site-root) — `transport.ts:32` already resolves via `VITE_GATEWAY_URL`, left
as-is. Service worker skipped on the native channel (`lib/pwa.svelte.ts`). Payments hidden in the MVP
via new `purchasesHidden()` (`lib/distribution.ts`: folds `VITE_PAYMENTS_DISABLED` + the GP flag, plus
a `?nopay` mock force); the `Wallet.svelte` buy tab shows a neutral pointer-free note
(`wallet.purchasesSoon`) for the RuStore MVP, RuStore stub kept GP-only. Native env types in
`vite-env.d.ts`. `svelte-check` 0 errors, `vitest` 590 passed, web + native `vite build` clean;
`?nopay`/`?gp` wallet states verified live via the Playwright MCP browser (the e2e runner can't fetch
browsers in this sandbox — the states are covered by `e2e/wallet.spec.ts`, which CI runs).
- **C. Client-version gate — ✅ DONE & verified (2026-07-12).** Server: new `gateway/internal/clientver`
(parse + compare), `GATEWAY_MIN_CLIENT_VERSION` config (empty ⇒ dormant, validated at load), and the
gate in `connectsrv``Execute` returns `result_code="update_required"` before the registry lookup,
`Subscribe` returns `FailedPrecondition`; fail-open on an absent/garbled header. Client:
`X-Client-Version` on every call (`transport.ts headers()`), a terminal `update.svelte.ts` store +
`UpdateOverlay.svelte` (native → `VITE_STORE_URL`, web → reload), `retry.ts` maps
`FailedPrecondition → update_required`, the `__update` mock hook. `gofmt`/`vet` clean, Go
`clientver`/config/`connectsrv` tests green, `svelte-check` 0, `vitest` 591, e2e 232 (incl.
`update.spec.ts`), client build clean. Silent reconciliation seam deferred to D (owner); in-app
store-update SDK noted Out of scope.
- **D. Offline-first — 🚧 IN PROGRESS (foundations done & committed `bcd5a1d`, 2026-07-12).** The
additive, web-inert groundwork landed; the boot rewrite + reconciliation + Profile soft-sign-in +
tests remain (see §D for the detailed remaining path and the session decisions). Done:
`ui/scripts/bundle-dicts.mjs` (release DAWGs → `dist/dict/<variant>@<version>.dawg`, keyed on
`VITE_DICT_VERSION`, `OUT_DIR` override for the e2e); the `dict/loader.ts` **bundled tier** (between
IndexedDB and network, **native-gated** — see the §D.1 correction); `__DICT_VERSION__` vite define +
declaration; `lib/localguest.ts` (persisted device-local guest id, no DB row) + `common.guest` i18n;
`NewGame.svelte` offline vs_ai/hotseat creates fall back to `__DICT_VERSION__` and
`localGuestId()`/`t('common.guest')` when there is no session (inert until the boot lands).
`svelte-check` 0, `vitest` 591, native + web builds clean.
- **EG — pending.**
Open logistics (not code): **mandatory icon rebrand (future)** — author ONE vector master and generate
*every* icon from it (web `favicon.svg` / PWA `icon-*` / maskable, Android adaptive, future iOS). Today
the formats drift — `favicon.svg` is a rounded bordered tile, `icon-maskable-512.png` a full-bleed
square, visibly different — and the Android launcher icon is only a temporary upscale of the maskable
(`capacitor-assets` insets it 16.7% into the adaptive safe zone). `~/.claude/bin/gitea-ci-watch.py` is present.
RuStore publication prerequisites gate release only.
---
## Prerequisites to start DEVELOPMENT (on the other device)
Have all of these before writing code:
- **JDK 21** (Temurin/OpenJDK) — **required** by Capacitor 8: `@capacitor/android` sets
`sourceCompatibility/targetCompatibility = VERSION_21`, so a JDK 17 Gradle run fails with
`invalid source release: 21`. (Homebrew: `brew install openjdk@21`.)
- **Android SDK** — Android Studio (recommended: bundles the SDK manager + AVD emulator) *or*
cmdline-tools. Capacitor 8 targets **compileSdk/targetSdk 36, minSdk 24**, so install:
`platform-tools`, `platforms;android-36`, `build-tools;36.0.0` (a newer build-tools such as
`36.1.0` is also accepted — AGP treats it as a minimum). Gradle 8.14.3 / AGP 8.13 arrive via the
wrapper `cap add android` generates.
- **Node 20+ and pnpm** (via corepack), matching the repo's `ui` toolchain.
- **Git**, plus this repo's Gitea access. If this device will push/PR, set up the `tea` CLI login
and the CI watcher exactly as the owner's global setup (`~/.claude` tooling:
`python3 ~/.claude/bin/gitea-ci-watch.py`, `tea` against `gitea.lan`).
- **Both repositories side by side** per `go.work`: clone `developer/scrabble-game` **and** the
sibling `scrabble-solver` next to it — the solver **library** the backend/CI consume via the
`go.work` replace. **The bundled dictionaries do NOT come from the solver.** The versioned,
production dictionary set is published by `developer/scrabble-dictionary` as a **release artifact**
`scrabble-dawg-<DICT_VERSION>.tar.gz` (one semver label per set); `backend/Dockerfile` and every CI
job already `curl` exactly that tarball, keyed on the shared Gitea variable `DICT_VERSION`.
Offline-first bundles the DAWGs from that same release (see D.1 / E), so the Android build needs
`DICT_VERSION` + network access to the release, **not** `scrabble-solver`. (`scrabble-solver/dawg/*.dawg`
are the solver's committed test fixtures — byte-identical to a build but pinned to the solver
commit, not the versioned production set.)
- **A test target**: a physical Android device with USB debugging **or** an emulator AVD (e.g. a
Pixel AVD; any recent API image).
- **No backend needed to build the APK** — it points at production `erudit-game.ru`. A local gateway
is only needed to exercise the version gate locally (optional).
Publication prerequisites (RuStore account, keystore, listing assets) are listed at the **end**
they gate *release*, not development.
---
## Identity model (answers "how do guests work offline vs in the DB")
Split the **local play identity** from the **server account**. This *extends* the current model
(guests are DB rows) with a pre-server local state; existing server-guest semantics are unchanged.
- **Local guest (device-local, no DB row).** A device-generated id + a default display name,
persisted on the device. Exists from the very first launch, with no network. It fills the human
seat in a local vs_ai game and is the "you" for local games. A purely-offline user never creates a
DB row (consumes no server resources).
- **Server guest (DB row).** Minted lazily via `auth.guest` the first time the app reaches the
network, its session cached and reused. Unlocks online features (matchmaking, friends, online
games). Exactly one per device — guarded by the cached session (never mint if one exists).
- **Registered account.** Email upgrade as today; adopts the current server guest.
- **Local games stay device-only.** Both local vs_ai and local hotseat games already persist only on
the device (`ui/src/lib/localgame/store.ts`); they never sync to the server, regardless of identity
transitions. Reconciliation to a server guest does **not** migrate them.
**Reconciliation flow:** on gaining network with no server session, silently `auth.guest` in the
background, cache the session, adopt it. This is best-effort — see the gate interaction below for how
a too-old client stays offline instead of being interrupted.
---
## The client-version gate — architecture (read before touching gate code)
**Principle: the version rides the outermost, permanently-stable layer, never the FlatBuffers
payload.** The transport is two layers: a protobuf Connect envelope
(`ExecuteRequest{message_type, payload, request_id}`, `gateway/proto/edge/v1/edge.proto`) wrapping a
FlatBuffers payload (`pkg/fbs/scrabble.fbs`). Protobuf envelopes and HTTP headers are
version-tolerant by design; the FBS payload is the layer that breaks. So the client version rides in
an **HTTP header `X-Client-Version`**, read by the gateway **before it decodes the payload**.
**Enforcement is per-call, not a dedicated init RPC.** The client attaches the header to every
Connect call via its single `headers()` builder; the gateway checks it at the top of `Execute`
(before registry lookup / auth / payload decode) and `Subscribe`. The first online call the app makes
(session establish `auth.*`) is thus gated automatically — no `Hello` RPC needed. A too-old client
makes **zero** successful requests but sees a recognizable signal, not a crash.
**Two return shapes, one meaning:**
- `Execute` → the domain-style envelope `result_code = "update_required"` (HTTP 200); the client
already throws `GatewayError(result_code)` for any non-`ok`.
- `Subscribe` → Connect `CodeFailedPrecondition` (a stream has no `result_code`).
**Frozen contract (write into `docs/ARCHITECTURE.md` §2):** three things become permanent so any
build, however old, can always recognize "update required": (1) the protobuf envelope field numbers
in `edge.proto` are never renumbered/reused; (2) the `update_required` sentinel (the `result_code`
string + the `FailedPrecondition` code) never changes; (3) the FBS schema stays **additive**
(trailing fields only, `(deprecated)` never delete). Breaking changes happen only *inside* the FBS
payload.
**Discipline (write into `deploy/README.md`):** the production deploy that ships an incompatible wire
change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same rollout. Until
deliberately set, `GATEWAY_MIN_CLIENT_VERSION` is **empty ⇒ the gate is dormant and web behaviour is
unchanged.** One hard threshold for the MVP; a soft "recommended update" tier is deferred.
**Interaction with offline-first (important UX rule):** offline mode uses the network kill switch
(`transport.ts assertOnline`), so the gate never fires while playing offline — an old client can
always play local vs_ai/hotseat. The gate matters only for online actions. Therefore the terminal
"update" overlay must be raised **only on a user-initiated online action**, never on the silent
background guest-reconciliation: if reconciliation's `auth.guest` returns `update_required`, swallow
it and stay a local guest (do not overlay). Implementation seam: the reconciliation path catches the
`update_required` code and does not route it to the global overlay trigger; foreground calls do.
---
## Work breakdown
Ordered so each part is independently verifiable. The MVP now includes offline-first, so the sequence
is: scaffold → native-correct → gate → offline-first → CI → docs → release.
### A. Capacitor scaffolding — ✅ DONE
- `ui/package.json`: add deps `@capacitor/core`, `@capacitor/android`, `@capacitor/app`; devDeps
`@capacitor/cli`, `@capacitor/assets`. Scripts: `"cap:sync": "cap sync android"`,
`"android:assets": "capacitor-assets generate --android"`.
- `ui/capacitor.config.ts` (new):
```ts
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'ru.eruditgame.app',
appName: 'Эрудит',
webDir: 'dist',
// Bundle model: no server.url — the WebView loads the packaged dist/ from app assets. Updates
// ship through the store; the client-version gate turns away a build too old to speak the
// current wire contract (docs/ARCHITECTURE.md §2).
};
export default config;
```
- `npx cap add android` → generates and **commits** `ui/android/` (a Gradle project). Neither
`.gitignore` lists it today; keep it tracked so build.gradle + signing edits are reviewable and CI
reproducible. Ensure `ui/android/.gitignore` covers build outputs (`/app/build`, `/build`,
`/.gradle`, `/local.properties`, `*.keystore`, `*.jks`).
- **Android hardware Back** — new `ui/src/lib/native.ts`, dynamically importing `@capacitor/app` so
the web/mock bundle never pulls it:
```ts
import { clientChannel } from './channel';
export async function initNativeShell(atNavigationRoot: () => boolean): Promise<void> {
const ch = clientChannel();
if (ch !== 'android' && ch !== 'ios') return;
const { App } = await import('@capacitor/app');
App.addListener('backButton', () => {
if (atNavigationRoot()) void App.exitApp();
else history.back();
});
}
```
Call once from bootstrap. `atNavigationRoot` = current route is `lobby`/`login`
(mirror `routeDepth(...) === 0` from `App.svelte:50-54`, reading `router.svelte.ts`).
- **Launcher icon (owner asset):** owner supplies a 1024×1024 «Э» icon at `ui/assets/icon.png`
(+ optional `splash.png`); `pnpm android:assets` generates adaptive icons.
**Done when:** `npx cap sync android` succeeds against a real `pnpm build`, and (from `ui/android/`)
`./gradlew assembleDebug` produces a debug APK that installs and cold-launches to the login screen.
### B. Native web-build correctness (file:// origin) — ✅ DONE
1. **One origin helper** — new `ui/src/lib/origin.ts`:
```ts
// The absolute origin the client talks to. A packaged native build (file:// origin) sets
// VITE_GATEWAY_URL to the real gateway; web/mock leave it empty and fall back to the page
// origin. Centralised so every absolute-URL construction resolves identically.
export function gatewayOrigin(): string {
const configured = import.meta.env.VITE_GATEWAY_URL ?? '';
return configured || (typeof location !== 'undefined' ? location.origin : '');
}
```
Fix the two same-origin **landmines** (they would produce `file:///…` on native):
- `ui/src/game/Game.svelte:1214`: `new URL(path, location.origin)` → `new URL(path, gatewayOrigin())`.
- `ui/src/screens/Wallet.svelte:47`: `` `${location.origin}/` `` → `` `${gatewayOrigin()}/` ``.
(`transport.ts:32` already computes the equivalent inline — leave it.)
2. **Skip the service worker on native** — `ui/src/lib/pwa.svelte.ts registerServiceWorker()` (line 85):
add, next to `if (inMiniApp()) return;`:
```ts
import { clientChannel } from './channel';
const ch = clientChannel();
if (ch === 'android' || ch === 'ios') return;
```
3. **Hide payments in the MVP build** — `ui/src/lib/distribution.ts`, add:
```ts
// purchasesHidden: true for the Google Play build (external-payment policy) OR the thin native
// MVP that defers store billing (VITE_PAYMENTS_DISABLED="1"). Every other build sells normally.
export function purchasesHidden(): boolean {
return isGooglePlayBuild() || (import.meta.env as Record<string, string | undefined>).VITE_PAYMENTS_DISABLED === '1';
}
```
Switch the wallet/purchase consumers from `isGooglePlayBuild()` to `purchasesHidden()` where they
hide the buy actions (the sole consumer is `Wallet.svelte`). Keep the "go to RuStore" stub
`isGooglePlayBuild()`-only. **As built (owner decision):** in the RuStore MVP (`purchasesHidden() &&
!isGooglePlayBuild()`) the buy tab shows a neutral, pointer-free note — new i18n key
`wallet.purchasesSoon`, `data-testid="purchases-hidden"` — not an empty tab and not a store link; the
same note can replace the RuStore stub in the later Google Play anti-steering variant.
`purchasesHidden()` also carries a mock-only `?nopay` force (mirrors `?gp`) so the e2e drives the
state without a separate build. Add `VITE_PAYMENTS_DISABLED?: string`, `VITE_STORE_URL?: string`,
`VITE_DICT_VERSION?: string` to `ui/src/vite-env.d.ts`.
4. **Native env matrix** — see Build & env.
**Done when:** a native-flavoured build reaches the gateway, signs in as guest, plays a move, and the
purchase actions are absent; `pnpm check` + `pnpm test:unit` pass.
### C. The client-version gate — ✅ DONE
#### C1. Backend (gateway)
- **New package `gateway/internal/clientver`** (`clientver.go` + `_test.go`): dependency-free parse of
the leading `v?MAJOR.MINOR.PATCH` (ignore any `-N-gSHA`/`+meta` suffix) + compare:
```go
package clientver
import ("strconv"; "strings")
type Version struct{ Major, Minor, Patch int }
func Parse(s string) (Version, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if i := strings.IndexAny(s, "-+"); i >= 0 { s = s[:i] }
p := strings.SplitN(s, ".", 4)
if len(p) < 3 { return Version{}, false }
var v Version; var err error
if v.Major, err = strconv.Atoi(p[0]); err != nil { return Version{}, false }
if v.Minor, err = strconv.Atoi(p[1]); err != nil { return Version{}, false }
if v.Patch, err = strconv.Atoi(p[2]); err != nil { return Version{}, false }
return v, true
}
func Less(a, b Version) bool {
if a.Major != b.Major { return a.Major < b.Major }
if a.Minor != b.Minor { return a.Minor < b.Minor }
return a.Patch < b.Patch
}
```
Tests: `"v1.16.0"`, `"1.16.0"`, `"v1.16.0-3-gabc"`, `"dev"`/`""`→!ok, ordering incl. equal.
- **Config `gateway/internal/config/config.go`**: add `MinClientVersion string` (doc: empty ⇒ gate
off); `Load()`: `c.MinClientVersion = os.Getenv("GATEWAY_MIN_CLIENT_VERSION")`; `validate()`: if
non-empty and `!clientver.Parse(...ok)` → return a config error. Extend `config_test.go`.
- **Server `gateway/internal/connectsrv/server.go`**:
- consts `clientVersionHeader = "X-Client-Version"`, `resultUpdateRequired = "update_required"`;
`errors.go`: `errUpdateRequired = errors.New("client too old, update required")`.
- `Server`: add `minClient clientver.Version` + `gateOn bool`. `Deps`: add `MinClientVersion string`.
`NewServer`: parse `d.MinClientVersion` once (set `gateOn` on success; `log.Warn` + off if
unparseable).
- helper `clientTooOld(header string) bool`: `if !s.gateOn { return false }`; parse header, on
`!ok` return false (fail-open); return `clientver.Less(v, s.minClient)`.
- `Execute`: insert **after** the `defer` metrics line (after `server.go:296`, before
`registry.Lookup`), so `msgType` is set and the payload is untouched:
```go
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(), ResultCode: resultUpdateRequired,
}), nil
}
```
- `Subscribe`: at the top (before `resolve`): `if s.clientTooOld(req.Header().Get(clientVersionHeader)) { return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired) }`.
- `main.go`: add `MinClientVersion: cfg.MinClientVersion,` to the `connectsrv.Deps{...}` literal.
- Tests (`server_test.go`): too-old Execute ⇒ `result_code == "update_required"` and the op handler
never ran; too-old Subscribe ⇒ `FailedPrecondition`; absent header / empty min / unparseable
header / equal version ⇒ pass.
#### C2. Client (ui)
- **`ui/src/lib/update.svelte.ts`** (new, terminal — no poll):
```ts
export const UPDATE_REQUIRED = 'update_required';
let required = $state(false);
export const updateRequired = { get active(): boolean { return required; } };
export function reportUpdateRequired(): void { required = true; }
```
- **`ui/src/lib/transport.ts`**:
- `headers()` (line 37) always attaches the version header:
```ts
const headers = (): Record<string, string> => {
const h: Record<string, string> = { 'x-client-version': __APP_VERSION__ };
if (token) h.authorization = `Bearer ${token}`;
return h;
};
```
- result-code branch (line 86): `if (res.resultCode === UPDATE_REQUIRED) reportUpdateRequired();`
before the throw.
- catch (after `toGatewayError`, line 73) and the `subscribe()` catch: if the code is
`UPDATE_REQUIRED`, call `reportUpdateRequired()` before rethrowing/`onError`.
- **Reconciliation exception — deferred to D (owner decision).** The silent update-path variant
(swallows `update_required` without raising the overlay) has no caller until D.4, so it is added
there with its caller rather than speculatively in C. In C every foreground call that gets
`update_required` raises the overlay; offline play never trips it (the network kill switch).
- **`ui/src/lib/retry.ts` `toGatewayError()`**: add
`case Code.FailedPrecondition: return new GatewayError('update_required', e.message);`.
- **`ui/src/components/UpdateOverlay.svelte`** (new, mirror `MaintenanceOverlay.svelte`): non-dismissable;
shown when `updateRequired.active`; one button — native (`clientChannel()` android/ios) →
`window.open(import.meta.env.VITE_STORE_URL, '_system')`; web → `location.reload()`. i18n keys
`update.title/body/action` (add siblings to the maintenance keys).
- **`ui/src/App.svelte`**: import + place `<UpdateOverlay />` right after `<MaintenanceOverlay />` (line 139).
- **Mock e2e hook** — `ui/src/lib/gateway.ts` mock branch, next to `__maint`: `__update = { on: reportUpdateRequired }`.
- **Tests:** extend `retry.test.ts` (`FailedPrecondition → 'update_required'`) and drive the overlay via
Playwright `update.spec.ts` (`__update.on()`). The `update.svelte.ts` store is a `$state` rune module,
which this project's plugin-less `vitest` cannot import (`$state is not defined`); like every other
`*.svelte.ts` store it is covered by the e2e, not a unit test.
**Done when:** Go `clientver` + gate tests pass; `pnpm check`/`test:unit`/`test:e2e` pass; a local
gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` shows the overlay, unset ⇒ unchanged.
### D. Offline-first (bundled dicts + local guest + cold boot + reconciliation) — 🚧 IN PROGRESS
Both offline modes already exist and are gated on `offlineMode.active`: local vs_ai and 2-4-player
hotseat (pass-and-play with a host PIN referee) — see `ui/src/lib/localgame/source.hotseat.test.ts`
and `ui/src/screens/NewGame.svelte:177-431`. This phase makes them reachable on a **cold first launch
with no network**.
**Status:** the additive foundations (D.1, D.2) are **done & committed (`bcd5a1d`)** and inert on the
web; the boot rewrite (D.3), reconciliation + the silent seam (D.4), the Profile soft-sign-in (D.6) and
the tests remain. **Verify every line ref below against current code** (they are as of 2026-07-12).
**Decisions locked this session (owner-approved) — bake these before implementing:**
- **The blocking Login is bypassed on native only.** Web / PWA / Telegram / VK keep the current
online-session rule (they still need a prior session). The native channel always lands the user in the
lobby, online or offline.
- **Soft registration reuses the existing `Profile` screen** as the guest sign-in / account surface (it
already shows the email / Telegram / VK upgrade options for a guest). No new sign-in UI is built in D.
- **Hide the Telegram + VK link buttons on the native build** on Profile: VK ID web-login is a full-page
redirect to `id.vk.com` that cannot return into the Capacitor app (it strands on the web redirect URI),
and the Telegram Login Widget is unreliable in a WebView. **Email works** (pure gateway calls, no
redirect). Native Telegram/VK login (native SDKs / deep-link OAuth — "the pretty native popups") is a
**separate later stage**, consistent with the locked "guest+email" surface and "Out of scope: VK/Telegram
login in the native build".
- **Local-guest display name** = localized `common.guest` ("Гость" / "Guest").
1. **Bundle dictionaries in the APK. ✅ DONE.**
- `ui/scripts/bundle-dicts.mjs`: copies the DAWGs from the unpacked **`scrabble-dictionary` release**
(dir via **`DICT_DIR`**; **NOT** `../scrabble-solver/dawg`) → `<OUT_DIR|dist>/dict/<variant>@<version>.dawg`.
`dictKey(variant,version)` = `` `${variant}@${version}` `` (`lib/dict/store.ts:31`). `dawgFor` maps
`scrabble_en→en_sowpods`, `scrabble_ru→ru_scrabble`, `erudit_ru→ru_erudit` (mirrors `e2e-dict.mjs`).
Version = `VITE_DICT_VERSION` (default `dev`); `OUT_DIR` overrides the root (the e2e points it at
`dist-e2e`). Run only in the native pipeline (after `pnpm build`, before `cap sync`); web builds skip
it and stay slim.
- Vite `define` `__DICT_VERSION__` (from `VITE_DICT_VERSION`, default `dev`; mirrors `__APP_VERSION__`)
+ declared in `vite-env.d.ts`.
- **Loader tier** — `ui/src/lib/dict/loader.ts` `load()`: a **bundled tier** sits between the IndexedDB
tier (now tier 1) and the network tier (now tier 3). **CORRECTION vs the original plan:** it is
**native-gated** (`clientChannel()` android/ios), NOT "fetch always, 404 on web". A relative
`fetch('./dict/'+key+'.dawg')` on the web would hit the **gateway's own session-gated `/dict/` route**
(a real server route), not a clean 404 — so the bundled fetch is skipped off-native and only tried in
a packaged app (its own assets). On a hit: build the `Dawg`, cache in memory, seed IndexedDB, return
(no network metric — a bundled hit is a local asset). The e2e simulates native (see Tests).
- Offline creates request the **bundled version**: `NewGame.svelte` offline vs_ai (`~line 84`) and
hotseat (`~line 277`) use `app.profile?.dictVersions?.[v] ?? __DICT_VERSION__`, so a profile-less
local guest gets the bundled `(variant, version)`.
2. **Local-guest identity. ✅ DONE.** `ui/src/lib/localguest.ts`: `localGuestId()` mints + persists a
device-local id in `localStorage` (`scrabble.localGuestId`, prefix `localguest:`, no crypto API for the
old engines) + `isLocalGuestId()`. The **display name is not in the module** — it is `t('common.guest')`
at the call site, so the module stays i18n-free and node-testable. `NewGame.svelte` vs_ai human seat
(`~line 99`) now uses `accountId: app.session?.userId ?? localGuestId()` and
`name: app.profile?.displayName ?? t('common.guest')`. Hotseat seats stay independent local identities
(`buildSeats` — unchanged).
3. **Cold offline-first boot — ▢ TODO** (`ui/src/lib/offline.ts` + `ui/src/lib/app.svelte.ts`). **High
blast-radius: this is app startup for every platform — gate every change on the native channel and
leave the web / PWA / TG / VK paths byte-for-byte.**
- The blocking Login is **`bootstrap()` app.svelte.ts ~line 989-991**:
`} else if (router.route.name !== 'login' && router.route.name !== 'confirm') { navigate('/login'); }`
— the `else` of `if (saved)` (no cached session). **On native, replace this branch** with the
offline-first entry: establish the local guest (`localGuestId()`), `setOfflineMode(true, false)`
(**non-sticky** auto-offline, so reconciliation may clear it — cf. the sticky arg at ~line 968), and
land in the **lobby** (leave the route / `navigate('/')`), `app.ready = true`. Never navigate to
`/login` on native.
- The native no-session boot **always lands in the lobby**, online or offline. If reachable at boot,
also kick reconciliation (D.4); if not, stay a local guest until the network returns. The lobby
renders without a session — offline mode shows local games and `NewGame` gates its offline flows on
`guest || offlineMode.active`.
- `shouldBootOffline` (offline.ts:85, currently `offlineActive && hasSession && hasProfile`): add a
**native-no-session** path so a native cold start can boot offline as the local guest with no cached
session/profile. Keep the web rule intact.
- `offlinePreloadEligible` (offline.ts:69): **bypass the server preload on native** — the dicts are
bundled, so there is nothing to preload; keep the web `standalone && hasEmail` gate.
4. **Lazy server-guest reconciliation + the silent seam — ▢ TODO** (the seam was deferred from C).
- **Silent seam:** the C transport calls `reportUpdateRequired()` in three places — `exec` result-code
branch (`transport.ts ~line 86`), `exec` catch (`~line 73`), `subscribe` catch (`~line 366`). Add a
**silent path** that does NOT raise the overlay: e.g. an `exec(msgType, payload, signal, opts?: {
silent?: boolean })` flag, plus an `authGuestSilent(locale)` on the `GatewayClient` interface
(`lib/client.ts`), the real transport, **and the mock** (`lib/mock/client.ts` — the e2e reconciliation
leg goes through the mock). Foreground `auth.*` keep the overlay.
- **Reconciliation:** when native + online + **no** server session → background `authGuestSilent`,
`saveSession` + adopt (`setToken` + `app.session`/`app.profile`, or reuse `adoptSession`), then
**clear the auto-offline** (`setOfflineMode(false)`) so online features + Profile light up. Guard
duplicates (only when no cached session — never mint a second server guest). On `update_required` it
stays offline silently (no overlay). Local games stay device-only. Fire it on boot (if reachable) and
on network-recovery (a connection-online handler).
5. **Both offline modes render for the local guest — ✅ mostly (verify at D close).** `NewGame` gates the
offline flows on `guest || offlineMode.active`; the local guest makes `offlineMode.active` true, so both
"quick" (vs_ai) and "with friends" (hotseat) show. The vs_ai human seat already uses the local-guest id
(D.2).
6. **Soft registration = reuse the `Profile` screen — ▢ TODO.**
- Profile is already reachable for an **online** guest (guests keep the Profile tab —
`SettingsHub.svelte:27` only hides friends/wallet for guests; `:33` hides profile/friends/wallet in
**offline** mode). So once the native guest is online and reconciled (D.4 clears the auto-offline),
Profile shows and email sign-in works. Offline you cannot register anyway (every method needs the
server), so the offline-hidden behaviour is correct — **no change is needed to reach Profile**, provided
D.4 clears the auto-offline when online.
- **Hide tg/vk on native:** `Profile.svelte` — the Telegram link button (`~line 485`, gated on
`telegramLinkable = loginWidgetAvailable()`, `:62`) and the VK link button (`~line 499`). Add a
`clientChannel()` android/ios check to hide both on the native build; keep email + account management.
(See the decision block above for why.)
**Tests — ▢ TODO** (docs/TESTING.md layers; the mock e2e bypasses the codec, so the gate's server path
stays in the Go tests):
- **Unit (vitest, node):** the bundled-dict tier — a `fetch` mock returning a dawg blob **with the native
channel forced** (`vi.stubGlobal('window', { Capacitor: { getPlatform: () => 'android' } })`, since the
tier is native-gated; `idbPutDawg`/`requestPersist` are best-effort `void`); `shouldBootOffline`
native-no-session; `localguest` (`localGuestId` persistence + `isLocalGuestId`). (`update.svelte.ts`-style
reminder: a `$state` rune module can't be imported under this project's plugin-less vitest.)
- **Playwright offline-first spec:** **simulate native** by injecting `window.Capacitor = { getPlatform:
() => 'android' }` via `addInitScript` (makes `clientChannel()` return `android`, activating the bundled
tier + the native boot). Place bundled dicts at **`dist-e2e/dict/<variant>@dev.dawg`** — extend the
`playwright.config.ts` webServer command to also run `DICT_DIR=$E2E_DICT_DIR OUT_DIR=dist-e2e node
scripts/bundle-dicts.mjs` (`VITE_DICT_VERSION` unset → `dev`, matching `__DICT_VERSION__`). Boot with the
network hook off (`window.__conn.offline()` / the offline pref) → assert the **lobby** (not login), play a
local vs_ai move, start a hotseat game; then network on → reconciliation lights up.
**GOTCHA:** with `window.Capacitor` injected, `initNativeShell` (`App.svelte:40`) will `await
import('@capacitor/app')` and call `App.addListener('backButton', …)` — there is no real Capacitor
bridge in the browser, so guard/stub it (inject a fake `Capacitor.Plugins.App`, or make `initNativeShell`
tolerate a missing bridge) or the boot may throw.
**Done when:** a native build with airplane mode on cold-launches to the lobby as a guest, starts and
plays both a local vs_ai game and a 2-player hotseat game with no network; turning the network on
silently establishes a server guest (Profile + online play light up). Emulator smoke recipe: `.claude/CLAUDE.md`
→ "Native Android build", with the extra `DICT_DIR=<release> VITE_DICT_VERSION=<ver> node
scripts/bundle-dicts.mjs` between `pnpm build` and `cap sync`.
### E. CI — signed APK artifact
New **manual** workflow `.gitea/workflows/android-build.yaml` (mirror `prod-deploy.yaml`'s
`workflow_dispatch` + `confirm` gate; from `master`; not on PRs). Steps:
1. Checkout this repo; copy the Node/pnpm setup block from `.gitea/workflows/ci.yaml` (the `ui` job).
Add the **"Fetch dictionary DAWGs"** step verbatim from `ci.yaml` (the `curl …
scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz` +
untar into `${GITHUB_WORKSPACE}/dawg`), keyed on the `DICT_VERSION` Gitea variable — **this**, not
the `scrabble-solver` sibling, is the source of the bundled dicts. (`scrabble-solver` is not needed
by the Android build: `ui` is a Node project outside `go.work`.)
2. `pnpm install --frozen-lockfile`.
3. `pnpm build` with the native env (below), `VITE_APP_VERSION` = `git describe --tags`,
`VITE_DICT_VERSION` = the release dict version.
4. `DICT_DIR=${GITHUB_WORKSPACE}/dawg node ui/scripts/bundle-dicts.mjs` (copy the release `.dawg`
files into `ui/dist/dict/`).
5. Setup JDK 21 (`actions/setup-java@v4`, temurin 21).
6. Install Android SDK via `sdkmanager` (pin `platform-tools`, `platforms;android-36`,
`build-tools;36.0.0`); cache the SDK. **No emulator** — assemble only; on-device smoke is manual.
7. `npx cap sync android`.
8. Decode the keystore from `ANDROID_KEYSTORE_BASE64`; compute `versionCode`/`versionName` from the
tag (scheme below).
9. `cd ui/android && ./gradlew assembleRelease -PversionCode=$CODE -PversionName=$NAME` with the
signing config reading the keystore + `ANDROID_KEYSTORE_PASSWORD`/`ANDROID_KEY_ALIAS`/
`ANDROID_KEY_PASSWORD` from env.
10. Upload `ui/android/app/build/outputs/apk/release/*.apk` as an artifact.
Watch to green with `python3 ~/.claude/bin/gitea-ci-watch.py` (background). RuStore upload is manual
for the MVP.
**`versionCode`/`versionName` scheme** (deterministic from the tag): `v{MA}.{MI}.{PA}` →
`versionCode = MA*1_000_000 + MI*1_000 + PA` (e.g. `v1.17.0` → `1017000`), `versionName = "{MA}.{MI}.{PA}"`.
Wire in `ui/android/app/build.gradle` `defaultConfig`:
`versionCode (project.findProperty('versionCode') ?: '1').toInteger()`,
`versionName (project.findProperty('versionName') ?: '0.0.0')`, plus a `signingConfigs.release`
reading env/props (guarded so a local `assembleDebug` still builds unsigned).
### F. Docs (bake in the same PR)
- `docs/ARCHITECTURE.md`: §2 Transport — the client-version gate + the **frozen wire contract** +
the gate×offline rule. New sections — the **identity model** (local guest / server guest /
reconciliation) and the **native Android build** (Capacitor bundle model, bundled dicts,
`versionCode` scheme, RuStore, `file://`-origin implications).
- `docs/FUNCTIONAL.md` (+ `docs/FUNCTIONAL_ru.md` mirror): user stories — offline-first guest launch
(vs_ai + hotseat with no network), soft registration, "update required when too old", native
distribution (no purchases in the MVP).
- `docs/TESTING.md`: the new Go tests (`clientver`, gate), the client tests, the bundled-dict tier
test, and the **manual on-device Android smoke checklist** (installs; airplane-mode cold launch to
guest lobby; local vs_ai move; 2-player hotseat; Back button; share link resolves to the gateway;
network on → online lights up; overlay when `GATEWAY_MIN_CLIENT_VERSION` is bumped).
- Config/README docs: new vars `GATEWAY_MIN_CLIENT_VERSION`, `VITE_STORE_URL`,
`VITE_PAYMENTS_DISABLED`, `VITE_DICT_VERSION`, and the native `VITE_GATEWAY_URL` value.
- `deploy/README.md`: the Android build/release runbook (keystore + secrets, dispatch, RuStore
upload) **and** the discipline rule — bump `GATEWAY_MIN_CLIENT_VERSION` in the same prod deploy that
ships an incompatible wire change.
### G. Release + owner handoff
1. Merge `feature/android-native` → `development`; verify on the contour (contour-safe; gate dormant).
2. Promote `development` → `master`; tag `vX.Y.0`.
3. Dispatch `android-build`; watch green; retrieve the signed APK.
4. Owner runs the on-device smoke checklist (incl. airplane-mode first launch).
5. Owner uploads to RuStore (see publication prerequisites).
---
## Google Play variant (later — design now so we don't repaint)
One codebase, two build variants selected by flags:
- **RuStore build** (this MVP): `VITE_PAYMENTS_DISABLED=1` (MVP), `VITE_GP_BUILD` unset. Later, when
RuStore billing lands, RuStore sells normally.
- **Google Play build** (later): `VITE_GP_BUILD=1` ⇒ `purchasesHidden()` true. The **only** functional
difference is that purchases are hidden — the flag structure (`purchasesHidden()`) already carries
both cases, so no repaint is needed.
**Google Play compliance — decided: no RuStore steering.** Google forbids not just external billing
but *steering* users to other payment methods or app stores. So the Google Play build hides purchases
with **no** pointer to RuStore: the existing `isGooglePlayBuild()` RuStore-pointer stub is **dropped**
in the GP variant (show nothing, or a neutral pointer-free message — never name or link RuStore or any
other store). This does not affect the RuStore MVP (there `isGooglePlayBuild()` is false, so the stub
never shows). Google Play also requires an **AAB** (not APK) and Play App Signing — a separate CI
target, built later.
---
## Build & env matrix
**Native Android build** (`pnpm build` → `bundle-dicts` → `cap sync`):
- `VITE_GATEWAY_URL=https://erudit-game.ru` — absolute origin (the native-critical var).
- `VITE_STORE_URL=https://www.rustore.ru/catalog/app/ru.eruditgame.app` — the update-overlay target.
- `VITE_APP_VERSION=$(git describe --tags)` — feeds `__APP_VERSION__` = the `X-Client-Version` header.
- `VITE_DICT_VERSION=<release dict version>` — the bundled-dict version the offline path requests.
- `VITE_PAYMENTS_DISABLED=1` — hide purchases in the MVP. (`VITE_GP_BUILD` unset — RuStore, not GP.)
**Web/contour/prod builds:** unchanged. `VITE_GATEWAY_URL` empty (same-origin),
`GATEWAY_MIN_CLIENT_VERSION` unset (gate dormant). Nothing about web behaviour changes until a future
breaking release deliberately sets the min version.
---
## Prerequisites to start PUBLICATION (RuStore)
- **RuStore developer account** — a registered legal entity or self-employed (самозанятый) RU status,
verified. Gates publication (not the build).
- **Release keystore** — generate once and **back up immutably** (losing it means never updating this
app again):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`.
Set Gitea repo secrets: `ANDROID_KEYSTORE_BASE64` (base64 of the .jks),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`.
- **The signed APK** — from the `android-build` artifact.
- **Store listing** — reserve `ru.eruditgame.app`; title «Эрудит»; RU description; phone screenshots;
icon/feature graphic; category; age rating; a hosted **privacy-policy URL**; the RuStore content
declaration.
- (For the later **Google Play** variant: a Google Play Console account ($25), the GP build (payments
hidden + the anti-steering fix), an **AAB**, Play App Signing, and the Play Data-safety form.)
## Risks & mitigations
- **Keystore loss** → app can never be updated. Back up off-host immediately.
- **Gate dormant until first bump** → its real firing only happens on a future breaking release. The
Go `server_test.go` tests + the Playwright `__update` e2e prove the whole path now.
- **Update overlay interrupting offline play** → the gate×offline rule (overlay only on user-initiated
online actions; silent reconciliation swallows `update_required`).
- **`X-Client-Version` stripped by the edge** → Caddy forwards request headers by default (the RPC
route already reaches the gateway); no Caddyfile change. Confirm via the smoke (bump min → overlay).
- **APK size from bundled dicts** → a few MB per variant; acceptable. Could trim to RU-only later.
- **RuStore account gate** (legal status) → surface to the owner before release.
## Out of scope (deferred)
Google Play publication (beyond keeping the codebase variant-ready), iOS, OTA/live-updates, in-app
purchases in the native build (RuStore billing SDK) and the GP anti-steering fix, VK/Telegram login in
the native build, a soft "recommended update" tier, **in-app store-update flows** (Google Play In-App
Updates API / RuStore In-App Update SDK — a future enhancement that would swap the update overlay's
action from opening the store listing to a store-driven immediate in-app update; needs a Capacitor
plugin bridge, and is orthogonal to the server-driven gate), native splash/status-bar plugins, push
notifications (FCM), and automated RuStore-API upload.
## End-to-end verification
- **Unit/integration:** `go test ./gateway/...` (clientver + gate), `cd ui && pnpm check &&
pnpm test:unit && pnpm test:e2e`, `gofmt -l .` clean, `go vet ./gateway/...`.
- **Gate proof:** gateway with `GATEWAY_MIN_CLIENT_VERSION=v99.0.0` ⇒ overlay; unset ⇒ normal.
- **Offline-first proof:** native `assembleDebug`, install, **airplane mode**, cold launch → guest
lobby → play a local vs_ai move and a 2-player hotseat game; network on → server guest established.
- **Native build proof:** guest sign-in online, a move, Back navigates then exits at root, a
share/export link points at `erudit-game.ru`, purchases absent.
- **Signed pipeline:** dispatch `android-build`; watcher green; the release APK installs and launches.
- **Contour:** the `feature/android-native` PR deploys cleanly (no schema/wire regen; gate dormant);
web/VK/TG behaviour unchanged.
-8
View File
@@ -156,11 +156,3 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
## Agent field notes
Non-obvious, hard-won knowledge the agent has accumulated that is **not** captured in the docs above,
kept in the repo so it travels with a clone. Verify any named file/flag against current code before
acting on it.
@.claude/CLAUDE.md
-7
View File
@@ -283,13 +283,6 @@ 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.
@@ -4,7 +4,6 @@ package inttest
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
@@ -112,44 +111,3 @@ 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 -82
View File
@@ -1,12 +1,9 @@
package payments
import (
"cmp"
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"github.com/google/uuid"
@@ -150,76 +147,13 @@ 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
return s.store.createProduct(ctx, in, active, s.clock())
}
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
@@ -232,11 +166,7 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
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
return s.store.updateProduct(ctx, id, in, s.clock())
}
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
@@ -251,19 +181,11 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
return err
}
}
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
return s.store.setProductActive(ctx, id, active, s.clock())
}
// 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
return s.store.deleteProduct(ctx, id)
}
-44
View File
@@ -134,47 +134,3 @@ func TestProjectCatalog_Empty(t *testing.T) {
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
}
}
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
// ascending by chip price within a group. Stable and applied to active + archived alike.
func TestSortAdminCatalog(t *testing.T) {
pack := func(title string, rub int64) AdminProduct {
return AdminProduct{
Title: title,
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) AdminProduct {
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
for _, a := range atoms {
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
}
return p
}
products := []AdminProduct{
pack("packDear", 30000),
value("bundle", 500, "hints", "noads_days"),
pack("packCheap", 10000),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
SortAdminCatalog(products)
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
for i, p := range products {
if p.Title != want[i] {
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
}
}
}
// titles extracts the product titles for a failure message.
func titles(products []AdminProduct) []string {
out := make([]string, len(products))
for i, p := range products {
out[i] = p.Title
}
return out
}
-218
View File
@@ -1,218 +0,0 @@
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
@@ -1,137 +0,0 @@
package payments
import (
"strings"
"testing"
"github.com/google/uuid"
)
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
// value render into the two tables, pack table first, money formatted through Money.
func TestProjectOfferPricing(t *testing.T) {
entries := []catalogEntry{
{
id: uuid.New(),
title: "50 «Фишек»",
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
prices: []priceRow{
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
},
},
{
id: uuid.New(),
title: "200 подсказок",
atoms: []atomQty{{atomType: "hints", quantity: 200}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
},
}
md := projectOfferPricing(entries)
for _, want := range []string{
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
"| --- | ---: | ---: | ---: |", // price columns right-aligned
"| 50 «Фишек» | 200.00 | 30 | 100 |",
"| Наименование | «Фишки» |",
"| --- | ---: |",
"| 200 подсказок | 50 |",
} {
if !strings.Contains(md, want) {
t.Errorf("projection missing %q\n---\n%s", want, md)
}
}
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
t.Errorf("the pack table must precede the values table:\n%s", md)
}
}
// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by
// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group.
func TestProjectOfferPricingOrdering(t *testing.T) {
pack := func(title string, rub int64) catalogEntry {
return catalogEntry{
id: uuid.New(),
title: title,
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) catalogEntry {
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
for _, a := range atoms {
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
}
return e
}
// Deliberately out of order on input.
entries := []catalogEntry{
pack("packDear", 30000),
pack("packCheap", 10000),
value("bundle", 500, "hints", "noads_days"),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
md := projectOfferPricing(entries)
order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
last := -1
for _, title := range order {
i := strings.Index(md, "| "+title+" |")
if i < 0 {
t.Fatalf("row %q missing:\n%s", title, md)
}
if i < last {
t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md)
}
last = i
}
}
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
// title carrying a pipe is escaped so the table layout survives.
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: "Bonus | pack",
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
// A roubles price only — no VK, no Telegram.
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
}}
md := projectOfferPricing(entries)
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
t.Errorf("want row %q in:\n%s", want, md)
}
}
// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link
// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the
// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not
// survive into the projected markdown.
func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: `<script>alert(1)</script> [x](javascript:alert(2)) & "q"`,
atoms: []atomQty{{atomType: "hints", quantity: 1}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}},
}}
md := projectOfferPricing(entries)
for _, bad := range []string{"<script>", "</script>", "[x]", `& "q"`} {
if strings.Contains(md, bad) {
t.Errorf("unescaped %q survived into the projection:\n%s", bad, md)
}
}
for _, want := range []string{"&lt;script&gt;", `\[x\]`, "&amp;", "&quot;q&quot;"} {
if !strings.Contains(md, want) {
t.Errorf("want escaped %q in:\n%s", want, md)
}
}
}
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
// headers), so the offer's pricing marker is replaced with nothing.
func TestProjectOfferPricingEmpty(t *testing.T) {
if got := projectOfferPricing(nil); got != "" {
t.Errorf("empty catalog must project to empty string, got %q", got)
}
}
-8
View File
@@ -5,7 +5,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/google/uuid"
@@ -20,13 +19,6 @@ import (
type Service struct {
store *Store
clock func() time.Time
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
offerMu sync.Mutex
offerMD string
offerFresh bool
}
// NewService constructs a Service over store with a wall-clock time source.
-3
View File
@@ -74,9 +74,6 @@ func (s *Server) registerRoutes() {
u.POST("/wallet/buy", s.handleWalletBuy)
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
u.POST("/wallet/reward", s.handleWalletReward)
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
s.internal.GET("/offer/pricing", s.handleOfferPricing)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
@@ -41,9 +41,6 @@ func (s *Server) consoleCatalog(c *gin.Context) {
s.consoleError(c, err)
return
}
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
payments.SortAdminCatalog(products)
var view adminconsole.CatalogView
for _, p := range products {
view.Products = append(view.Products, catalogRow(p))
@@ -112,22 +112,6 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
c.JSON(http.StatusOK, catalogDTOFrom(view))
}
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
// projected from the active products. The render sidecar fetches it and splices it into the offer
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
// the steady state). Called by the renderer, not the gateway.
func (s *Server) handleOfferPricing(c *gin.Context) {
md, err := s.payments.OfferPricing(c.Request.Context())
if err != nil {
s.log.Error("offer pricing projection failed", zap.Error(err))
c.String(http.StatusInternalServerError, "offer pricing unavailable")
return
}
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, md)
}
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
// unconfigured), which gates the client's "watch for chips" button.
-3
View File
@@ -115,9 +115,6 @@ 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`. |
| `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). |
-9
View File
@@ -107,15 +107,6 @@
}
}
# 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
+3 -6
View File
@@ -3,10 +3,8 @@
# 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 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.
# 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.
# 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
@@ -52,8 +50,7 @@ services:
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
# 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.
# No telemetry export: otelcol is on the main host, unreachable from here.
TELEGRAM_OTEL_TRACES_EXPORTER: none
TELEGRAM_OTEL_METRICS_EXPORTER: none
GOMAXPROCS: "1"
-9
View File
@@ -84,9 +84,6 @@ 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
@@ -254,12 +251,6 @@ 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
@@ -1,45 +0,0 @@
{
"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,31 +53,6 @@
"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]))" }]
}
]
}
@@ -108,32 +108,6 @@ 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
@@ -303,91 +277,3 @@ groups:
- evaluator: { type: gt, params: [1800] }
labels: { severity: critical }
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.' }
+12 -3
View File
@@ -18,9 +18,18 @@
@shell not path /assets/*
header @shell Cache-Control "no-cache"
# 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).
# 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
}
}
# An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below.
-5
View File
@@ -77,11 +77,6 @@ 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
+4 -49
View File
@@ -909,22 +909,6 @@ 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
@@ -1015,20 +999,6 @@ 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
@@ -1087,9 +1057,7 @@ 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, 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
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link. 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
@@ -1206,18 +1174,6 @@ 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
@@ -1393,10 +1349,9 @@ in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/
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; `/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
**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
**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
+1 -5
View File
@@ -30,11 +30,7 @@ render with arbitrary languages, so detection would make the indexed content
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page. The footer links to the **public
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
the published prices always match what is currently on sale — no redeploy to update them.
shell is `noindex` — the landing is the only indexable page.
On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
+1 -6
View File
@@ -32,12 +32,7 @@ top-1 подсказку, безлимитную проверку слова с
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
сейчас в продаже — без передеплоя.
`noindex` — индексируется только посадочная страница.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
-86
View File
@@ -1,86 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/ratelimit"
)
const (
// blocklistFetchTimeout bounds one feed fetch.
blocklistFetchTimeout = 30 * time.Second
// maxBlocklistBytes caps the fetched feed (Spamhaus DROP is well under 1 MiB; the cap stops a
// hostile or misconfigured URL from streaming unbounded data into memory).
maxBlocklistBytes = 8 << 20
)
// parseAllowlist parses the never-block entries (CIDRs or bare IPs, a bare IP read as a /32) into
// networks. A malformed entry is a fatal config error — the operator must fix it, not silently lose
// a protected range.
func parseAllowlist(entries []string) ([]*net.IPNet, error) {
out := make([]*net.IPNet, 0, len(entries))
for _, e := range entries {
s := strings.TrimSpace(e)
if s == "" {
continue
}
if !strings.Contains(s, "/") {
s += "/32"
}
_, n, err := net.ParseCIDR(s)
if err != nil {
return nil, fmt.Errorf("gateway: GATEWAY_BLOCKLIST_ALLOW: %q: %w", e, err)
}
out = append(out, n)
}
return out, nil
}
// runBlocklistRefresher keeps the community IP blocklist feed current: it fetches immediately, then
// every cfg.Refresh, and applies each outcome through ratelimit.ApplyRefresh (keep-last-good on a
// transient failure; drop the feed once it goes stale, fail-open). It returns when ctx is cancelled.
func runBlocklistRefresher(ctx context.Context, bl *ratelimit.Blocklist, cfg config.BlocklistConfig, log *zap.Logger) {
client := &http.Client{Timeout: blocklistFetchTimeout}
for {
cidrs, err := fetchBlocklist(ctx, client, cfg.URL)
switch ratelimit.ApplyRefresh(bl, cidrs, err, time.Now(), cfg.MaxStaleness) {
case ratelimit.RefreshUpdated:
log.Info("blocklist refreshed", zap.String("url", cfg.URL), zap.Int("entries", bl.Len()))
case ratelimit.RefreshKept:
log.Warn("blocklist refresh failed; keeping the last-good feed", zap.Error(err))
case ratelimit.RefreshDropped:
log.Warn("blocklist refresh failed and the feed is stale; dropped it (fail-open)", zap.Error(err))
}
select {
case <-ctx.Done():
return
case <-time.After(cfg.Refresh):
}
}
}
// fetchBlocklist GETs the feed and parses it, bounded by the fetch timeout and the size cap.
func fetchBlocklist(ctx context.Context, client *http.Client, url string) ([]*net.IPNet, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gateway: blocklist fetch %s: status %d", url, resp.StatusCode)
}
return ratelimit.ParseDROP(io.LimitReader(resp.Body, maxBlocklistBytes))
}
+15 -26
View File
@@ -129,11 +129,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Window: cfg.Abuse.BanWindow,
Duration: cfg.Abuse.BanDuration,
})
allow, err := parseAllowlist(cfg.Blocklist.Allow)
if err != nil {
return err
}
blocklist := ratelimit.NewBlocklist(cfg.Blocklist.Enabled, allow)
hub := push.NewHub(0)
var validator transcode.TelegramValidator
@@ -221,23 +216,21 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
registry := transcode.NewRegistry(backend, validator, regOpts...)
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes,
MinClientVersion: cfg.MinClientVersion,
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
Logger: logger,
AdminProxy: adminProxy,
Meter: tel.MeterProvider().Meter("scrabble/gateway/edge"),
MaxBodyBytes: cfg.MaxBodyBytes,
})
// Bridge the backend push stream into the fan-out hub (and the out-of-app
@@ -250,10 +243,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
if cfg.Abuse.BanEnabled {
go runBanSync(ctx, banlist, backend, logger)
}
// When the edge blocklist is enabled (prod), keep the community feed refreshed.
if cfg.Blocklist.Enabled {
go runBlocklistRefresher(ctx, blocklist, cfg.Blocklist, logger)
}
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
servers := []*namedServer{{name: "public", srv: public}}
-48
View File
@@ -76,11 +76,6 @@ type Hub struct {
connected metric.Int64UpDownCounter
commands metric.Int64Counter
// tgErrors counts the remote bot's Bot API failures it reports over the stream, by kind
// (connect / api / rate_limited). lastOK holds the wall-clock second of the bot's most recent
// successful Bot API call, read by the bot_tg_last_ok_unix observable gauge for a staleness alert.
tgErrors metric.Int64Counter
lastOK atomic.Int64
}
// link is one connected bot's outbound queue and identity.
@@ -108,19 +103,6 @@ func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *H
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
h.commands, _ = meter.Int64Counter("botlink_commands_total",
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
h.tgErrors, _ = meter.Int64Counter("bot_tg_errors_total",
metric.WithDescription("Telegram Bot API failures the remote bot reports over the bot-link, by kind (connect, api, rate_limited)."))
// The bot's last successful Bot API second, reported over the stream. An observable gauge
// reads the atomic on each collection; it observes nothing until a bot has reported, so a
// never-connected gateway shows no data (the connected-bots alert covers that case).
_, _ = meter.Int64ObservableGauge("bot_tg_last_ok_unix",
metric.WithDescription("Unix second of the remote bot's most recent successful Bot API call (0/absent until reported)."),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
if v := h.lastOK.Load(); v > 0 {
o.Observe(v)
}
return nil
}))
}
return h
}
@@ -167,36 +149,6 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.
if ack := msg.GetAck(); ack != nil {
h.resolve(ack)
}
if hb := msg.GetHealth(); hb != nil {
h.recordHealth(hb)
}
}
}
// recordHealth folds one bot Health report into the gateway's cumulative Bot API metrics: the three
// delta counters are added under their kind label, and the last-ok stamp (an absolute wall-clock
// second) advances the liveness gauge (monotonically — a stale reordered report cannot rewind it).
func (h *Hub) recordHealth(hb *botlinkv1.Health) {
if h.tgErrors != nil {
ctx := context.Background()
if v := hb.GetConnectFailures(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "connect")))
}
if v := hb.GetApiErrors(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "api")))
}
if v := hb.GetRateLimited(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "rate_limited")))
}
}
for {
cur := h.lastOK.Load()
if hb.GetLastOkUnix() <= cur {
break
}
if h.lastOK.CompareAndSwap(cur, hb.GetLastOkUnix()) {
break
}
}
}
-18
View File
@@ -233,21 +233,3 @@ func TestRelayServerNoBot(t *testing.T) {
t.Fatal("expected an error with no bot connected")
}
}
// TestRecordHealthLastOKMonotonic checks a bot Health report advances the last-ok liveness stamp but
// a stale or reordered report (an earlier second) never rewinds it.
func TestRecordHealthLastOKMonotonic(t *testing.T) {
hub := NewHub(nil, nil, nil)
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 100})
if got := hub.lastOK.Load(); got != 100 {
t.Fatalf("lastOK = %d, want 100", got)
}
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 90}) // reordered/stale — must not rewind
if got := hub.lastOK.Load(); got != 100 {
t.Errorf("lastOK rewound to %d, want 100", got)
}
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 150})
if got := hub.lastOK.Load(); got != 150 {
t.Errorf("lastOK = %d, want 150", got)
}
}
-57
View File
@@ -1,57 +0,0 @@
// Package clientver parses and compares the leading MAJOR.MINOR.PATCH of a client
// version string so the edge can turn away a build too old to speak the current wire
// contract. It is deliberately dependency-free and tolerant: the version rides an HTTP
// header (X-Client-Version) that a build stamps from `git describe --tags`, so any
// `-N-gSHA` or `+meta` suffix is ignored, and anything unparseable is reported as such
// (the caller fails open — an absent or garbled header is never treated as too old).
package clientver
import (
"strconv"
"strings"
)
// Version is a parsed semantic version triple. Only MAJOR.MINOR.PATCH participate in the
// ordering; any pre-release or build suffix is dropped at parse time.
type Version struct {
Major, Minor, Patch int
}
// Parse extracts the leading MAJOR.MINOR.PATCH from s, tolerating an optional leading
// "v" and any `-N-gSHA` or `+meta` suffix (as produced by `git describe --tags`). It
// reports ok=false when s has fewer than three numeric components or any component is not
// an integer, so the caller can distinguish a real version from a dev/empty string.
func Parse(s string) (Version, bool) {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
if i := strings.IndexAny(s, "-+"); i >= 0 {
s = s[:i]
}
p := strings.SplitN(s, ".", 4)
if len(p) < 3 {
return Version{}, false
}
var v Version
var err error
if v.Major, err = strconv.Atoi(p[0]); err != nil {
return Version{}, false
}
if v.Minor, err = strconv.Atoi(p[1]); err != nil {
return Version{}, false
}
if v.Patch, err = strconv.Atoi(p[2]); err != nil {
return Version{}, false
}
return v, true
}
// Less reports whether a orders before b by MAJOR, then MINOR, then PATCH. Equal versions
// are not Less than each other, so a client exactly at the minimum passes the gate.
func Less(a, b Version) bool {
if a.Major != b.Major {
return a.Major < b.Major
}
if a.Minor != b.Minor {
return a.Minor < b.Minor
}
return a.Patch < b.Patch
}
@@ -1,63 +0,0 @@
package clientver
import "testing"
// TestParse covers the version strings the edge actually sees: a plain and a "v"-prefixed
// triple, a `git describe --tags` suffix, build metadata, surrounding space, and the
// non-version strings (dev/empty/too-few/non-numeric) that must report ok=false so the
// gate fails open.
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
want Version
wantK bool
}{
{"plain", "1.16.0", Version{1, 16, 0}, true},
{"v-prefixed", "v1.16.0", Version{1, 16, 0}, true},
{"git describe suffix", "v1.16.0-3-gabc1234", Version{1, 16, 0}, true},
{"build metadata", "1.16.0+ci42", Version{1, 16, 0}, true},
{"surrounding space", " v2.0.1 ", Version{2, 0, 1}, true},
{"extra component ignored", "v1.16.0.4", Version{1, 16, 0}, true},
{"dev", "dev", Version{}, false},
{"empty", "", Version{}, false},
{"too few components", "1.16", Version{}, false},
{"non-numeric patch", "1.16.x", Version{}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := Parse(tc.in)
if ok != tc.wantK {
t.Fatalf("Parse(%q) ok = %v, want %v", tc.in, ok, tc.wantK)
}
if ok && got != tc.want {
t.Errorf("Parse(%q) = %+v, want %+v", tc.in, got, tc.want)
}
})
}
}
// TestLess covers the ordering by MAJOR, then MINOR, then PATCH, and that an equal version
// is not Less (a client exactly at the minimum passes the gate).
func TestLess(t *testing.T) {
tests := []struct {
name string
a, b Version
want bool
}{
{"equal", Version{1, 16, 0}, Version{1, 16, 0}, false},
{"major less", Version{1, 9, 9}, Version{2, 0, 0}, true},
{"major greater", Version{2, 0, 0}, Version{1, 9, 9}, false},
{"minor less", Version{1, 16, 5}, Version{1, 17, 0}, true},
{"minor greater", Version{1, 17, 0}, Version{1, 16, 9}, false},
{"patch less", Version{1, 16, 0}, Version{1, 16, 1}, true},
{"patch greater", Version{1, 16, 2}, Version{1, 16, 1}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := Less(tc.a, tc.b); got != tc.want {
t.Errorf("Less(%+v, %+v) = %v, want %v", tc.a, tc.b, got, tc.want)
}
})
}
}
+8 -89
View File
@@ -6,10 +6,8 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry"
)
@@ -55,17 +53,10 @@ type Config struct {
// MaxBodyBytes caps one inbound request body on the public listener and one
// Connect message read; oversized requests are refused without buffering.
MaxBodyBytes int
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
// the edge serves. A client reporting an older X-Client-Version is turned away with an
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments.
MinClientVersion string
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
Abuse AbuseConfig
// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
Blocklist BlocklistConfig
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config
}
@@ -175,42 +166,6 @@ func DefaultAbuse() AbuseConfig {
}
}
// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
// test contour). Only IPv4 is matched.
type BlocklistConfig struct {
// Enabled turns edge blocklisting on. Off by default (prod-only).
Enabled bool
// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
// operator sets it explicitly so no feed URL is assumed.
URL string
// Refresh is how often the feed is re-fetched.
Refresh time.Duration
// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
// (fail-open — a legitimate client is never blocked on a frozen feed).
MaxStaleness time.Duration
// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
// the feed can never block. Parsed at startup.
Allow []string
}
// Blocklist defaults; the feed URL has no default (the operator sets it).
const (
defaultBlocklistRefresh = 6 * time.Hour
defaultBlocklistMaxStaleness = 48 * time.Hour
)
// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
// the agreed refresh / staleness windows.
func DefaultBlocklist() BlocklistConfig {
return BlocklistConfig{
Enabled: false,
Refresh: defaultBlocklistRefresh,
MaxStaleness: defaultBlocklistMaxStaleness,
}
}
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
@@ -232,15 +187,14 @@ func (c VKIDConfig) Enabled() bool {
func Load() (Config, error) {
var err error
c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
MinClientVersion: os.Getenv("GATEWAY_MIN_CLIENT_VERSION"),
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
VKID: VKIDConfig{
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
@@ -249,7 +203,6 @@ func Load() (Config, error) {
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
Blocklist: DefaultBlocklist(),
BotLink: BotLinkConfig{
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
@@ -291,17 +244,6 @@ func Load() (Config, error) {
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
return Config{}, err
}
if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
return Config{}, err
}
c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
return Config{}, err
}
if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
return Config{}, err
}
c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
return Config{}, err
}
@@ -339,17 +281,9 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
}
if c.MinClientVersion != "" {
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED")
}
if c.BotLink.Addr != "" {
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
@@ -370,21 +304,6 @@ func envOr(key, fallback string) string {
return fallback
}
// splitList splits a comma-separated environment value into trimmed, non-empty items.
func splitList(v string) []string {
if v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// envBool parses the environment variable named key as a bool, returning fallback
// when it is unset and an error when it is set but malformed.
func envBool(key string, fallback bool) (bool, error) {
-23
View File
@@ -47,29 +47,6 @@ func TestLoadMaxBodyBytes(t *testing.T) {
}
}
// TestLoadMinClientVersion verifies the client-version gate config: dormant (empty) by
// default, a parseable version accepted, and an unparseable one rejected.
func TestLoadMinClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.MinClientVersion != "" {
t.Errorf("MinClientVersion = %q, want empty (gate dormant)", c.MinClientVersion)
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid min version: %v", err)
}
if c.MinClientVersion != "v1.16.0" {
t.Errorf("MinClientVersion = %q, want %q", c.MinClientVersion, "v1.16.0")
}
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_MIN_CLIENT_VERSION, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) {
+7 -31
View File
@@ -2,7 +2,6 @@ package connectsrv_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
@@ -25,7 +24,7 @@ const honeypotHeader = "X-Scrabble-Honeypot"
// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake
// backend, returning the front URL, a Connect client and a cleanup func.
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -37,7 +36,6 @@ func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Banlist: bl,
Blocklist: block,
Honeytoken: honeytoken,
Hub: push.NewHub(0),
RateLimit: limits,
@@ -71,7 +69,7 @@ func enabledBanlist(threshold int) *ratelimit.Banlist {
func TestAbuseGuardBlocksBannedIP(t *testing.T) {
bl := enabledBanlist(100)
bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire)
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
@@ -88,7 +86,7 @@ func TestAbuseGuardBlocksBannedIP(t *testing.T) {
// and bans the client IP, so the next request is blocked.
func TestHoneypotHeaderTrips(t *testing.T) {
bl := enabledBanlist(100)
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for a honeypot hit")
})
defer cleanup()
@@ -120,7 +118,7 @@ func TestHoneypotHeaderTrips(t *testing.T) {
// banlist still 404s the decoy (detection/logging) but bans nothing.
func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) {
bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
@@ -152,7 +150,7 @@ func TestPublicRejectionStrikesBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.PublicPerMinute, limits.PublicBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
@@ -175,7 +173,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.UserPerMinute, limits.UserBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/internal/sessions/resolve":
_, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`))
@@ -204,7 +202,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
// caller and returns the ordinary invalid-session error without a backend call.
func TestHoneytokenBansAndRejects(t *testing.T) {
bl := enabledBanlist(100)
_, client, cleanup := guardedEdge(t, bl, nil, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for the honeytoken")
})
defer cleanup()
@@ -219,25 +217,3 @@ func TestHoneytokenBansAndRejects(t *testing.T) {
t.Fatal("the honeytoken must ban the caller")
}
}
// TestAbuseGuardBlocksBlocklistedIP verifies a client IP in the community blocklist feed is refused
// with 403 at the HTTP layer, before any handler runs.
func TestAbuseGuardBlocksBlocklistedIP(t *testing.T) {
block := ratelimit.NewBlocklist(true, nil)
_, feed, err := net.ParseCIDR("127.0.0.0/8")
if err != nil {
t.Fatal(err)
}
block.SetCIDRs([]*net.IPNet{feed}, time.Now())
url, _, cleanup := guardedEdge(t, nil, block, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
if err != nil {
t.Fatalf("get: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("blocklisted GET / = %d, want 403", resp.StatusCode)
}
}
-1
View File
@@ -12,7 +12,6 @@ var (
errInternal = errors.New("internal error")
errMissingToken = errors.New("missing session token")
errInvalidSession = errors.New("invalid or expired session")
errUpdateRequired = errors.New("client too old, update required")
)
// errUnknownMessageType reports an unregistered message type.
+1 -30
View File
@@ -7,8 +7,6 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
"scrabble/gateway/internal/ratelimit"
)
// meterName scopes the gateway edge's OpenTelemetry instruments.
@@ -29,7 +27,6 @@ type serverMetrics struct {
edge metric.Float64Histogram
rateLimited metric.Int64Counter
banned metric.Int64Counter
blocklisted metric.Int64Counter
active *activeUsers
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
localColdStart metric.Int64Counter
@@ -43,7 +40,7 @@ type serverMetrics struct {
// falling back to a no-op histogram on the (rare) construction error. The
// active_users gauge is registered as an observable callback over the in-memory
// tracker.
func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetrics {
func newServerMetrics(meter metric.Meter) *serverMetrics {
if meter == nil {
meter = noop.NewMeterProvider().Meter(meterName)
}
@@ -71,8 +68,6 @@ func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetric
}
m := &serverMetrics{
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
blocklisted: counterOf(meter, "gateway_blocklist_blocked_total",
"Requests refused at the edge by the community IP blocklist (Spamhaus DROP)."),
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
@@ -95,25 +90,6 @@ func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetric
return nil
}, gauge)
}
// Community blocklist status: the feed size and how stale it is, for the dashboard and the
// "feed not refreshing" alert. Age is observed only once a feed has loaded, so a disabled or
// never-fetched blocklist reports no age (and entries 0).
if bl != nil {
entries, e1 := meter.Int64ObservableGauge("gateway_blocklist_entries",
metric.WithDescription("CIDR ranges in the active community IP blocklist feed."))
age, e2 := meter.Float64ObservableGauge("gateway_blocklist_age_seconds",
metric.WithDescription("Seconds since the community IP blocklist feed was last successfully fetched (absent until the first fetch)."))
if e1 == nil && e2 == nil {
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
o.ObserveInt64(entries, int64(bl.Len()))
if last := bl.LastFetch(); !last.IsZero() {
o.ObserveFloat64(age, time.Since(last).Seconds())
}
return nil
}, entries, age)
}
}
return m
}
@@ -142,11 +118,6 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
// recordBlocklistBlock counts one request refused by the community IP blocklist.
func (m *serverMetrics) recordBlocklistBlock(ctx context.Context) {
m.blocklisted.Add(ctx, 1)
}
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
+4 -4
View File
@@ -16,7 +16,7 @@ func TestEdgeMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter, nil)
m := newServerMetrics(meter)
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
@@ -74,7 +74,7 @@ func TestRateLimitedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter, nil)
m := newServerMetrics(meter)
m.recordRateLimited(ctx, "user")
m.recordRateLimited(ctx, "user")
@@ -112,7 +112,7 @@ func TestBannedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter, nil)
m := newServerMetrics(meter)
m.recordBan(ctx, "tripwire")
m.recordBan(ctx, "tripwire")
@@ -150,7 +150,7 @@ func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter, nil)
m := newServerMetrics(meter)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
+1 -83
View File
@@ -26,7 +26,6 @@ import (
"golang.org/x/net/http2/h2c"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/clientver"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit"
@@ -47,16 +46,6 @@ const heartbeatKind = "heartbeat"
// spoofer, so trusting it is safe.
const honeypotHeader = "X-Scrabble-Honeypot"
// clientVersionHeader carries the client build version (stamped from `git describe --tags`) so
// the edge can turn away a build too old to speak the current wire contract, before it decodes
// the payload. resultUpdateRequired is the stable envelope result_code — with the Subscribe
// counterpart connect.CodeFailedPrecondition — that any build, however old, recognises as
// "you must update". Both are part of the frozen wire contract (docs/ARCHITECTURE.md §2).
const (
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
)
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report.
const (
@@ -88,7 +77,6 @@ type Server struct {
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
blocklist *ratelimit.Blocklist
honeytoken string
vkAppSecret string
hub *push.Hub
@@ -99,11 +87,6 @@ type Server struct {
maxBodyBytes int
// minClient is the lowest client version served; gateOn is false when the gate is dormant
// (GATEWAY_MIN_CLIENT_VERSION empty or unparseable).
minClient clientver.Version
gateOn bool
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
@@ -126,9 +109,6 @@ type Deps struct {
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
// (inert) banlist.
Banlist *ratelimit.Banlist
// Blocklist enforces the community IP blocklist on the hot path; nil selects a
// disabled (inert) blocklist.
Blocklist *ratelimit.Blocklist
// Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm.
Honeytoken string
@@ -144,10 +124,6 @@ type Deps struct {
// MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int
// MinClientVersion is the lowest client version (MAJOR.MINOR.PATCH) the edge serves; an
// older X-Client-Version is turned away with "update required". Empty or unparseable leaves
// the gate dormant.
MinClientVersion string
}
// NewServer constructs the edge service.
@@ -172,26 +148,10 @@ func NewServer(d Deps) *Server {
if banlist == nil {
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
}
blocklist := d.Blocklist
if blocklist == nil {
blocklist = ratelimit.NewBlocklist(false, nil)
}
rl := d.RateLimit
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
}
// Parse the minimum client version once. Config.validate already rejects an unparseable
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
// the gate dormant.
var minClient clientver.Version
gateOn := false
if d.MinClientVersion != "" {
if v, ok := clientver.Parse(d.MinClientVersion); ok {
minClient, gateOn = v, true
} else {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
}
}
return &Server{
registry: d.Registry,
sessions: d.Sessions,
@@ -200,16 +160,13 @@ func NewServer(d Deps) *Server {
limiter: limiter,
tracker: tracker,
banlist: banlist,
blocklist: blocklist,
honeytoken: d.Honeytoken,
hub: d.Hub,
heartbeat: d.Heartbeat,
log: log,
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter, blocklist),
metrics: newServerMetrics(d.Meter),
maxBodyBytes: maxBody,
minClient: minClient,
gateOn: gateOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -298,13 +255,6 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
http.Error(w, "banned", http.StatusTooManyRequests)
return
}
// The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work.
// Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist.
if s.blocklist.Blocked(ip) {
s.metrics.recordBlocklistBlock(r.Context())
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get(honeypotHeader) != "" {
s.log.Warn("honeypot tripwire",
zap.String("path", r.URL.Path),
@@ -319,22 +269,6 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
})
}
// clientTooOld reports whether the X-Client-Version header names a version below the configured
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
// false. The header is a client-controlled compatibility signal, not an access control (it is
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
// non-browser caller — must never be blocked spuriously.
func (s *Server) clientTooOld(header string) bool {
if !s.gateOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return clientver.Less(v, s.minClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
@@ -344,17 +278,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: resultUpdateRequired,
}), nil
}
op, ok := s.registry.Lookup(msgType)
if !ok {
result = "unknown_type"
@@ -424,11 +347,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
// Subscribe streams the authenticated user's live events with a keep-alive
// heartbeat until the client disconnects.
func (s *Server) Subscribe(ctx context.Context, req *connect.Request[edgev1.SubscribeRequest], stream *connect.ServerStream[edgev1.Event]) error {
// A stream carries no result_code, so a too-old client is refused with the Connect
// FailedPrecondition counterpart of the update_required sentinel.
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
return connect.NewError(connect.CodeFailedPrecondition, errUpdateRequired)
}
uid, _, _, err := s.resolve(ctx, req.Header(), peerIP(req.Peer().Addr, req.Header()))
if err != nil {
return err
+7 -91
View File
@@ -22,13 +22,8 @@ import (
)
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
// client plus a cleanup func. The client-version gate is off.
// client plus a cleanup func.
func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
return newEdgeMin(t, "", backendHandler)
}
// newEdgeMin is newEdge with the client-version gate armed at minVersion (empty leaves it off).
func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -36,13 +31,12 @@ func newEdgeMin(t *testing.T, minVersion string, backendHandler http.HandlerFunc
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
@@ -114,84 +108,6 @@ func TestExecuteGuestGate(t *testing.T) {
}
}
// TestExecuteVersionGate verifies the client-version gate on the unary path: a build older
// than the configured minimum is turned away with the update_required result code before the
// registry lookup (an unknown message type is gated too, proving the short-circuit), while an
// equal, newer, absent, or unparseable version passes; and the gate is dormant when unset.
func TestExecuteVersionGate(t *testing.T) {
// The backend answers auth.guest with a session; a gated call never reaches it.
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
header string
msgType string
want string
}{
{"too old is gated before lookup", "v1.16.0", "v1.0.0", "bogus.unknown", "update_required"},
{"equal passes", "v1.16.0", "v1.16.0", transcode.MsgAuthGuest, "ok"},
{"newer passes", "v1.16.0", "v2.0.0", transcode.MsgAuthGuest, "ok"},
{"absent header fails open", "v1.16.0", "", transcode.MsgAuthGuest, "ok"},
{"unparseable header fails open", "v1.16.0", "dev", transcode.MsgAuthGuest, "ok"},
{"gate dormant when unset", "", "v1.0.0", transcode.MsgAuthGuest, "ok"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: tc.msgType, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.want {
t.Fatalf("result_code = %q, want %q", got, tc.want)
}
})
}
}
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate).
func TestSubscribeVersionGate(t *testing.T) {
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
tests := []struct {
name string
min string
header string
want connect.Code
}{
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, noBackend)
defer cleanup()
req := connect.NewRequest(&edgev1.SubscribeRequest{})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
stream, err := client.Subscribe(context.Background(), req)
if err == nil {
for stream.Receive() {
}
err = stream.Err()
}
if got := connect.CodeOf(err); got != tc.want {
t.Fatalf("code = %v, want %v", got, tc.want)
}
})
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session")
-188
View File
@@ -1,188 +0,0 @@
package ratelimit
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"net"
"sort"
"strings"
"sync"
"time"
)
// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against.
type ipRange struct{ lo, hi uint32 }
// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced
// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an
// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is
// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it).
// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false.
type Blocklist struct {
enabled bool
allow []ipRange // sorted, non-overlapping; from config, immutable after construction
mu sync.RWMutex
ranges []ipRange // sorted, non-overlapping; the current feed
fetchedAt time.Time // last successful SetCIDRs; zero = never loaded
}
// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set
// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it.
func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist {
return &Blocklist{enabled: enabled, allow: toRanges(allow)}
}
// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is
// false on a disabled or empty blocklist, and false for any non-IPv4 address.
func (b *Blocklist) Blocked(ip string) bool {
if !b.enabled {
return false
}
v, ok := ipv4ToUint32(ip)
if !ok {
return false
}
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.ranges) == 0 || rangesContain(b.allow, v) {
return false
}
return rangesContain(b.ranges, v)
}
// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored.
func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) {
r := toRanges(cidrs)
b.mu.Lock()
b.ranges = r
b.fetchedAt = at
b.mu.Unlock()
}
// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps
// climbing. Blocked then returns false until a fresh feed loads.
func (b *Blocklist) Clear() {
b.mu.Lock()
b.ranges = nil
b.mu.Unlock()
}
// Len returns the number of ranges currently enforced.
func (b *Blocklist) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.ranges)
}
// LastFetch returns the time of the last successful feed load (zero if never).
func (b *Blocklist) LastFetch() time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
return b.fetchedAt
}
// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics.
type RefreshOutcome int
const (
// RefreshUpdated: a new feed was fetched and applied.
RefreshUpdated RefreshOutcome = iota
// RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept.
RefreshKept
// RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open).
RefreshDropped
)
// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it
// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open
// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock.
func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome {
if fetchErr == nil {
bl.SetCIDRs(cidrs, now)
return RefreshUpdated
}
last := bl.LastFetch()
if last.IsZero() || now.Sub(last) > maxStaleness {
bl.Clear()
return RefreshDropped
}
return RefreshKept
}
// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail,
// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and
// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed.
func ParseDROP(r io.Reader) ([]*net.IPNet, error) {
var out []*net.IPNet
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
line := sc.Text()
if i := strings.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !strings.Contains(line, "/") {
line += "/32"
}
_, ipnet, err := net.ParseCIDR(line)
if err != nil || ipnet.IP.To4() == nil {
continue
}
out = append(out, ipnet)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("ratelimit: read blocklist: %w", err)
}
return out, nil
}
// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs
// are dropped. Sorting by lo lets [rangesContain] binary-search.
func toRanges(cidrs []*net.IPNet) []ipRange {
out := make([]ipRange, 0, len(cidrs))
for _, n := range cidrs {
if n == nil {
continue
}
ip4 := n.IP.To4()
if ip4 == nil {
continue
}
ones, bits := n.Mask.Size()
if bits != 32 {
continue
}
lo := binary.BigEndian.Uint32(ip4)
hi := lo | (uint32(0xffffffff) >> uint(ones))
out = append(out, ipRange{lo: lo, hi: hi})
}
sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo })
return out
}
// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a
// binary search for the last range whose lo is at most v.
func rangesContain(ranges []ipRange, v uint32) bool {
i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v })
return i > 0 && ranges[i-1].hi >= v
}
// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4.
func ipv4ToUint32(s string) (uint32, bool) {
ip := net.ParseIP(s)
if ip == nil {
return 0, false
}
ip4 := ip.To4()
if ip4 == nil {
return 0, false
}
return binary.BigEndian.Uint32(ip4), true
}
@@ -1,106 +0,0 @@
package ratelimit
import (
"errors"
"net"
"strings"
"testing"
"time"
)
// cidrs parses CIDR strings into networks for a test fixture.
func cidrs(t *testing.T, ss ...string) []*net.IPNet {
t.Helper()
out := make([]*net.IPNet, 0, len(ss))
for _, s := range ss {
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("bad cidr %q: %v", s, err)
}
out = append(out, n)
}
return out
}
func TestBlocklistBlocked(t *testing.T) {
bl := NewBlocklist(true, cidrs(t, "10.20.30.0/24")) // allowlist
bl.SetCIDRs(cidrs(t, "1.2.3.0/24", "203.0.113.5/32", "198.51.100.0/28", "10.20.30.0/24"), time.Now())
cases := map[string]bool{
"1.2.3.0": true, // range start
"1.2.3.255": true, // range end
"1.2.4.0": false, // just past the /24
"203.0.113.5": true, // /32 exact
"203.0.113.6": false,
"198.51.100.15": true, // within /28
"198.51.100.16": false, // past the /28
"9.9.9.9": false, // not listed
"10.20.30.99": false, // listed BUT allowlisted — never blocked
"::1": false, // IPv6 — never matched here
"not-an-ip": false,
}
for ip, want := range cases {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%q) = %v, want %v", ip, got, want)
}
}
}
func TestBlocklistDisabledOrEmpty(t *testing.T) {
off := NewBlocklist(false, nil)
off.SetCIDRs(cidrs(t, "1.2.3.0/24"), time.Now())
if off.Blocked("1.2.3.4") {
t.Error("a disabled blocklist must not block")
}
empty := NewBlocklist(true, nil)
if empty.Blocked("1.2.3.4") {
t.Error("an empty (never-loaded) blocklist must not block")
}
}
func TestParseDROP(t *testing.T) {
in := "; Spamhaus DROP\n" +
"1.2.3.0/24 ; SBL1\n" +
" 198.51.100.0/28 ; SBL2\n" +
"\n" +
"203.0.113.5 ; a bare IP -> /32\n" +
"2001:db8::/32 ; IPv6 skipped\n" +
"garbage line\n"
nets, err := ParseDROP(strings.NewReader(in))
if err != nil {
t.Fatal(err)
}
if len(nets) != 3 {
t.Fatalf("got %d networks, want 3: %v", len(nets), nets)
}
bl := NewBlocklist(true, nil)
bl.SetCIDRs(nets, time.Now())
for ip, want := range map[string]bool{"1.2.3.9": true, "198.51.100.1": true, "203.0.113.5": true, "203.0.113.6": false, "2001:db8::1": false} {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%s) = %v, want %v", ip, got, want)
}
}
}
func TestApplyRefresh(t *testing.T) {
bl := NewBlocklist(true, nil)
now := time.Now()
if o := ApplyRefresh(bl, cidrs(t, "1.2.3.0/24"), nil, now, time.Hour); o != RefreshUpdated {
t.Fatalf("success: want RefreshUpdated, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Fatal("a successful refresh must apply the feed")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(30*time.Minute), time.Hour); o != RefreshKept {
t.Fatalf("fresh failure: want RefreshKept, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Error("a kept feed must still block")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(2*time.Hour), time.Hour); o != RefreshDropped {
t.Fatalf("stale failure: want RefreshDropped, got %v", o)
}
if bl.Blocked("1.2.3.4") {
t.Error("a stale feed must be dropped (fail-open)")
}
}
+79 -182
View File
@@ -30,14 +30,13 @@ const (
)
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command and a periodic Health report.
// Ack per Command.
type FromBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Msg:
//
// *FromBot_Hello
// *FromBot_Ack
// *FromBot_Health
Msg isFromBot_Msg `protobuf_oneof:"msg"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -98,15 +97,6 @@ func (x *FromBot) GetAck() *Ack {
return nil
}
func (x *FromBot) GetHealth() *Health {
if x != nil {
if x, ok := x.Msg.(*FromBot_Health); ok {
return x.Health
}
}
return nil
}
type isFromBot_Msg interface {
isFromBot_Msg()
}
@@ -119,92 +109,10 @@ type FromBot_Ack struct {
Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
}
type FromBot_Health struct {
Health *Health `protobuf:"bytes,3,opt,name=health,proto3,oneof"`
}
func (*FromBot_Hello) isFromBot_Msg() {}
func (*FromBot_Ack) isFromBot_Msg() {}
func (*FromBot_Health) isFromBot_Msg() {}
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
type Health struct {
state protoimpl.MessageState `protogen:"open.v1"`
ConnectFailures uint64 `protobuf:"varint,1,opt,name=connect_failures,json=connectFailures,proto3" json:"connect_failures,omitempty"` // getUpdates long-poll failures (transport / 5xx) since the last report
ApiErrors uint64 `protobuf:"varint,2,opt,name=api_errors,json=apiErrors,proto3" json:"api_errors,omitempty"` // other Bot API failures (transport / 5xx) since the last report
RateLimited uint64 `protobuf:"varint,3,opt,name=rate_limited,json=rateLimited,proto3" json:"rate_limited,omitempty"` // Bot API 429 responses since the last report (should stay ~0)
LastOkUnix int64 `protobuf:"varint,4,opt,name=last_ok_unix,json=lastOkUnix,proto3" json:"last_ok_unix,omitempty"` // unix seconds of the last successful Bot API response (0 = none yet)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Health) Reset() {
*x = Health{}
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Health) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Health) ProtoMessage() {}
func (x *Health) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Health.ProtoReflect.Descriptor instead.
func (*Health) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
}
func (x *Health) GetConnectFailures() uint64 {
if x != nil {
return x.ConnectFailures
}
return 0
}
func (x *Health) GetApiErrors() uint64 {
if x != nil {
return x.ApiErrors
}
return 0
}
func (x *Health) GetRateLimited() uint64 {
if x != nil {
return x.RateLimited
}
return 0
}
func (x *Health) GetLastOkUnix() int64 {
if x != nil {
return x.LastOkUnix
}
return 0
}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
type ToBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -215,7 +123,7 @@ type ToBot struct {
func (x *ToBot) Reset() {
*x = ToBot{}
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -227,7 +135,7 @@ func (x *ToBot) String() string {
func (*ToBot) ProtoMessage() {}
func (x *ToBot) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -240,7 +148,7 @@ func (x *ToBot) ProtoReflect() protoreflect.Message {
// Deprecated: Use ToBot.ProtoReflect.Descriptor instead.
func (*ToBot) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
}
func (x *ToBot) GetCommand() *Command {
@@ -263,7 +171,7 @@ type Hello struct {
func (x *Hello) Reset() {
*x = Hello{}
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -275,7 +183,7 @@ func (x *Hello) String() string {
func (*Hello) ProtoMessage() {}
func (x *Hello) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -288,7 +196,7 @@ func (x *Hello) ProtoReflect() protoreflect.Message {
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
func (*Hello) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
}
func (x *Hello) GetInstanceId() string {
@@ -325,7 +233,7 @@ type Command struct {
func (x *Command) Reset() {
*x = Command{}
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -337,7 +245,7 @@ func (x *Command) String() string {
func (*Command) ProtoMessage() {}
func (x *Command) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -350,7 +258,7 @@ func (x *Command) ProtoReflect() protoreflect.Message {
// Deprecated: Use Command.ProtoReflect.Descriptor instead.
func (*Command) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
}
func (x *Command) GetCommandId() string {
@@ -464,7 +372,7 @@ type Ack struct {
func (x *Ack) Reset() {
*x = Ack{}
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -476,7 +384,7 @@ func (x *Ack) String() string {
func (*Ack) ProtoMessage() {}
func (x *Ack) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -489,7 +397,7 @@ func (x *Ack) ProtoReflect() protoreflect.Message {
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
func (*Ack) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
}
func (x *Ack) GetCommandId() string {
@@ -537,7 +445,7 @@ type ChatGateCommand struct {
func (x *ChatGateCommand) Reset() {
*x = ChatGateCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -549,7 +457,7 @@ func (x *ChatGateCommand) String() string {
func (*ChatGateCommand) ProtoMessage() {}
func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -562,7 +470,7 @@ func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead.
func (*ChatGateCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
}
func (x *ChatGateCommand) GetExternalId() string {
@@ -590,7 +498,7 @@ type ChatEligibilityRequest struct {
func (x *ChatEligibilityRequest) Reset() {
*x = ChatEligibilityRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -602,7 +510,7 @@ func (x *ChatEligibilityRequest) String() string {
func (*ChatEligibilityRequest) ProtoMessage() {}
func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -615,7 +523,7 @@ func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead.
func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
}
func (x *ChatEligibilityRequest) GetExternalId() string {
@@ -638,7 +546,7 @@ type ChatEligibilityResponse struct {
func (x *ChatEligibilityResponse) Reset() {
*x = ChatEligibilityResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -650,7 +558,7 @@ func (x *ChatEligibilityResponse) String() string {
func (*ChatEligibilityResponse) ProtoMessage() {}
func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -663,7 +571,7 @@ func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead.
func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
}
func (x *ChatEligibilityResponse) GetRegistered() bool {
@@ -697,7 +605,7 @@ type CreateInvoiceCommand struct {
func (x *CreateInvoiceCommand) Reset() {
*x = CreateInvoiceCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -709,7 +617,7 @@ func (x *CreateInvoiceCommand) String() string {
func (*CreateInvoiceCommand) ProtoMessage() {}
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -722,7 +630,7 @@ func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
}
func (x *CreateInvoiceCommand) GetTitle() string {
@@ -766,7 +674,7 @@ type PreCheckoutRequest struct {
func (x *PreCheckoutRequest) Reset() {
*x = PreCheckoutRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -778,7 +686,7 @@ func (x *PreCheckoutRequest) String() string {
func (*PreCheckoutRequest) ProtoMessage() {}
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -791,7 +699,7 @@ func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
}
func (x *PreCheckoutRequest) GetOrderId() string {
@@ -827,7 +735,7 @@ type PreCheckoutResponse struct {
func (x *PreCheckoutResponse) Reset() {
*x = PreCheckoutResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -839,7 +747,7 @@ func (x *PreCheckoutResponse) String() string {
func (*PreCheckoutResponse) ProtoMessage() {}
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -852,7 +760,7 @@ func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
}
func (x *PreCheckoutResponse) GetOk() bool {
@@ -884,7 +792,7 @@ type ForwardPaymentRequest struct {
func (x *ForwardPaymentRequest) Reset() {
*x = ForwardPaymentRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -896,7 +804,7 @@ func (x *ForwardPaymentRequest) String() string {
func (*ForwardPaymentRequest) ProtoMessage() {}
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -909,7 +817,7 @@ func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
}
func (x *ForwardPaymentRequest) GetOrderId() string {
@@ -953,7 +861,7 @@ type ForwardPaymentResponse struct {
func (x *ForwardPaymentResponse) Reset() {
*x = ForwardPaymentResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -965,7 +873,7 @@ func (x *ForwardPaymentResponse) String() string {
func (*ForwardPaymentResponse) ProtoMessage() {}
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -978,7 +886,7 @@ func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{13}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
}
func (x *ForwardPaymentResponse) GetCredited() bool {
@@ -992,19 +900,11 @@ var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" +
"\n" +
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"\xa9\x01\n" +
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"r\n" +
"\aFromBot\x122\n" +
"\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" +
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ack\x125\n" +
"\x06health\x18\x03 \x01(\v2\x1b.scrabble.botlink.v1.HealthH\x00R\x06healthB\x05\n" +
"\x03msg\"\x97\x01\n" +
"\x06Health\x12)\n" +
"\x10connect_failures\x18\x01 \x01(\x04R\x0fconnectFailures\x12\x1d\n" +
"\n" +
"api_errors\x18\x02 \x01(\x04R\tapiErrors\x12!\n" +
"\frate_limited\x18\x03 \x01(\x04R\vrateLimited\x12 \n" +
"\flast_ok_unix\x18\x04 \x01(\x03R\n" +
"lastOkUnix\"?\n" +
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ackB\x05\n" +
"\x03msg\"?\n" +
"\x05ToBot\x126\n" +
"\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" +
"\x05Hello\x12\x1f\n" +
@@ -1076,49 +976,47 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
return file_botlink_v1_botlink_proto_rawDescData
}
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*Health)(nil), // 1: scrabble.botlink.v1.Health
(*ToBot)(nil), // 2: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 3: scrabble.botlink.v1.Hello
(*Command)(nil), // 4: scrabble.botlink.v1.Command
(*Ack)(nil), // 5: scrabble.botlink.v1.Ack
(*ChatGateCommand)(nil), // 6: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 7: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 8: scrabble.botlink.v1.ChatEligibilityResponse
(*CreateInvoiceCommand)(nil), // 9: scrabble.botlink.v1.CreateInvoiceCommand
(*PreCheckoutRequest)(nil), // 10: scrabble.botlink.v1.PreCheckoutRequest
(*PreCheckoutResponse)(nil), // 11: scrabble.botlink.v1.PreCheckoutResponse
(*ForwardPaymentRequest)(nil), // 12: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 13: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 14: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 15: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 16: scrabble.telegram.v1.SendToGameChannelRequest
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 2: scrabble.botlink.v1.Hello
(*Command)(nil), // 3: scrabble.botlink.v1.Command
(*Ack)(nil), // 4: scrabble.botlink.v1.Ack
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
(*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
}
var file_botlink_v1_botlink_proto_depIdxs = []int32{
3, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
5, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
1, // 2: scrabble.botlink.v1.FromBot.health:type_name -> scrabble.botlink.v1.Health
4, // 3: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
14, // 4: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
15, // 5: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
16, // 6: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
6, // 7: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
9, // 8: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
0, // 9: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
10, // 11: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
12, // 12: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
2, // 13: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
8, // 14: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
11, // 15: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
13, // 16: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
13, // [13:17] is the sub-list for method output_type
9, // [9:13] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
12, // [12:16] is the sub-list for method output_type
8, // [8:12] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
}
func init() { file_botlink_v1_botlink_proto_init() }
@@ -1129,9 +1027,8 @@ func file_botlink_v1_botlink_proto_init() {
file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{
(*FromBot_Hello)(nil),
(*FromBot_Ack)(nil),
(*FromBot_Health)(nil),
}
file_botlink_v1_botlink_proto_msgTypes[4].OneofWrappers = []any{
file_botlink_v1_botlink_proto_msgTypes[3].OneofWrappers = []any{
(*Command_Notify)(nil),
(*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil),
@@ -1144,7 +1041,7 @@ func file_botlink_v1_botlink_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
NumEnums: 0,
NumMessages: 14,
NumMessages: 13,
NumExtensions: 0,
NumServices: 1,
},
+1 -17
View File
@@ -48,30 +48,14 @@ service BotLink {
}
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command and a periodic Health report.
// Ack per Command.
message FromBot {
oneof msg {
Hello hello = 1;
Ack ack = 2;
Health health = 3;
}
}
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
// bot's Bot-API health as its own metrics the bot exports no telemetry of its own (otelcol lives
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
// long-poll that returns every poll cycle even when idle) an absolute liveness stamp the gateway
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
message Health {
uint64 connect_failures = 1; // getUpdates long-poll failures (transport / 5xx) since the last report
uint64 api_errors = 2; // other Bot API failures (transport / 5xx) since the last report
uint64 rate_limited = 3; // Bot API 429 responses since the last report (should stay ~0)
int64 last_ok_unix = 4; // unix seconds of the last successful Bot API response (0 = none yet)
}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
message ToBot {
Command command = 1;
-14
View File
@@ -23,7 +23,6 @@ import (
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/health"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
@@ -32,10 +31,6 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
// healthReportInterval is how often the bot flushes its accumulated Bot API health to the gateway
// over the bot-link. It bounds how stale a metric can be, not how often the bot talks to Telegram.
const healthReportInterval = 30 * time.Second
func main() {
cfg, err := config.LoadBot()
if err != nil {
@@ -86,12 +81,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
}
}
// The bot exports no telemetry of its own (otelcol is on the main host, unreachable from here),
// so its Bot API health is observed centrally and reported to the gateway over the bot-link,
// which turns the reports into metrics. Shared between the bot (which feeds it) and the bot-link
// client (which flushes it).
healthReporter := health.NewReporter()
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
@@ -103,7 +92,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
AcceptPayments: cfg.StarsOutboxDir != "",
Health: healthReporter,
}, logger)
if err != nil {
return err
@@ -132,8 +120,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
OwnsUpdates: cfg.OwnsUpdates,
Creds: credentials.NewTLS(tlsCfg),
ReconnectDelay: cfg.BotLink.ReconnectDelay,
Health: healthReporter,
HealthInterval: healthReportInterval,
}, exec, logger)
if err != nil {
return err
-16
View File
@@ -7,26 +7,19 @@ package bot
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"golang.org/x/time/rate"
"scrabble/platform/telegram/internal/health"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/support"
)
// botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set
// it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout.
const botPollTimeout = time.Minute
// Config configures the bot wrapper.
type Config struct {
// Token is the Bot API token.
@@ -61,9 +54,6 @@ type Config struct {
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
AcceptPayments bool
// Health, when set, observes every Bot API request for health metrics (reported to the gateway
// over the bot-link) and honours a 429's Retry-After. Nil disables the observation.
Health *health.Reporter
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -169,12 +159,6 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if cfg.APIBaseURL != "" {
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
}
if cfg.Health != nil {
// Observe every Bot API request centrally for health metrics and the 429 back-off. The
// client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side).
base := &http.Client{Timeout: botPollTimeout + 10*time.Second}
opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base)))
}
api, err := tgbot.New(cfg.Token, opts...)
if err != nil {
return nil, err
+14 -57
View File
@@ -11,7 +11,6 @@ import (
"google.golang.org/grpc/keepalive"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
"scrabble/platform/telegram/internal/health"
)
const (
@@ -35,11 +34,6 @@ type ClientConfig struct {
Creds credentials.TransportCredentials
// ReconnectDelay is the pause before re-dialing after the stream ends.
ReconnectDelay time.Duration
// Health, when set with a positive HealthInterval, is flushed to the gateway as a botlink Health
// message every HealthInterval while the stream is open. Nil disables health reporting.
Health *health.Reporter
// HealthInterval is the cadence of the Health flush; 0 disables it.
HealthInterval time.Duration
}
// Client maintains the long-lived bot-link to the gateway, executing the commands
@@ -146,59 +140,22 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
}
c.log.Info("bot-link connected", zap.String("gateway", c.cfg.GatewayAddr), zap.Bool("owns_updates", c.cfg.OwnsUpdates))
// One goroutine owns stream.Recv, feeding commands to the loop below; the loop owns every
// stream.Send (the Acks and the periodic Health) — a gRPC stream forbids concurrent Send.
rctx, cancel := context.WithCancel(ctx)
defer cancel()
cmds := make(chan *botlinkv1.Command)
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
if cmd := msg.GetCommand(); cmd != nil {
select {
case cmds <- cmd:
case <-rctx.Done():
return
}
}
}
}()
var healthTick <-chan time.Time
if c.cfg.Health != nil && c.cfg.HealthInterval > 0 {
t := time.NewTicker(c.cfg.HealthInterval)
defer t.Stop()
healthTick = t.C
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-recvErr:
msg, err := stream.Recv()
if err != nil {
return err
}
cmd := msg.GetCommand()
if cmd == nil {
continue
}
delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
if herr != nil {
ack.Error = herr.Error()
}
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
return err
case cmd := <-cmds:
delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
if herr != nil {
ack.Error = herr.Error()
}
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
return err
}
case <-healthTick:
// Snapshot without resetting; only commit (clear the deltas) after a successful send, so a
// failed flush loses nothing and the counts ride the next connection.
hb := c.cfg.Health.Snapshot()
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Health{Health: hb}}); err != nil {
return err
}
c.cfg.Health.Commit(hb)
}
}
}
-145
View File
@@ -1,145 +0,0 @@
// Package health tracks the Telegram bot's Bot API health and reports it to the gateway over the
// bot-link. The bot exports no telemetry of its own — otelcol lives on the main host, unreachable
// from the bot host — so the gateway turns these reports into its own metrics (see
// gateway/internal/botlink). Health is observed CENTRALLY by wrapping the Bot API HTTP client
// ([Reporter.Wrap]); every request is classified there with no per-call-site instrumentation.
package health
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
)
// maxBackoff caps how long a single 429 makes the bot wait, so a hostile or buggy Retry-After can
// never wedge the bot.
const maxBackoff = 30 * time.Second
// Reporter accumulates the bot's Bot API health between reports: three delta counters and the last
// successful-response stamp. It is safe for concurrent use. The bot-link client periodically
// [Reporter.Snapshot]s it, sends the snapshot as a botlink Health, and [Reporter.Commit]s it on a
// successful send.
type Reporter struct {
connectFailures atomic.Int64
apiErrors atomic.Int64
rateLimited atomic.Int64
lastOKUnix atomic.Int64
}
// NewReporter returns an empty Reporter.
func NewReporter() *Reporter { return &Reporter{} }
// Snapshot reads the current delta counters and the last-ok stamp into a Health message WITHOUT
// resetting them. The counters are cleared only by [Reporter.Commit] after a successful send, so a
// failed send loses nothing.
func (r *Reporter) Snapshot() *botlinkv1.Health {
return &botlinkv1.Health{
ConnectFailures: uint64(max(r.connectFailures.Load(), 0)),
ApiErrors: uint64(max(r.apiErrors.Load(), 0)),
RateLimited: uint64(max(r.rateLimited.Load(), 0)),
LastOkUnix: r.lastOKUnix.Load(),
}
}
// Commit subtracts a just-sent snapshot's delta counters, so counts accrued between the snapshot and
// the send survive into the next report. last_ok is a stamp, not a delta, so it is left as is.
func (r *Reporter) Commit(sent *botlinkv1.Health) {
r.connectFailures.Add(-int64(sent.GetConnectFailures()))
r.apiErrors.Add(-int64(sent.GetApiErrors()))
r.rateLimited.Add(-int64(sent.GetRateLimited()))
}
// Wrap returns an http.Client-shaped observer over base for tgbot.WithHTTPClient: it stamps liveness
// on every successful response, counts transport and 5xx failures (split getUpdates vs other) and
// 429s, and honours a 429's Retry-After (bounded by maxBackoff) so the bot backs off instead of
// hammering. A 4xx other than 429 (a user blocked the bot, a malformed request) is a normal
// per-request outcome, not a health signal, and is not counted.
func (r *Reporter) Wrap(base Doer) Doer { return &observer{base: base, r: r} }
// Doer is the minimal HTTP surface the Bot API client needs; both *http.Client and [observer]
// satisfy it, and it matches the go-telegram/bot HttpClient interface.
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
// observer is the Bot API HTTP client wrapper (see [Reporter.Wrap]).
type observer struct {
base Doer
r *Reporter
}
// Do performs the request and records its health outcome.
func (o *observer) Do(req *http.Request) (*http.Response, error) {
resp, err := o.base.Do(req)
poll := strings.HasSuffix(req.URL.Path, "/getUpdates")
if err != nil {
// A cancelled context is a shutdown, not a Bot API failure.
if req.Context().Err() == nil {
o.fail(poll)
}
return resp, err
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
o.r.rateLimited.Add(1)
o.backoff(req.Context(), resp)
case resp.StatusCode >= 200 && resp.StatusCode < 300:
o.r.lastOKUnix.Store(time.Now().Unix())
case resp.StatusCode >= 500:
o.fail(poll)
}
return resp, err
}
// fail records a connect failure on the getUpdates long-poll or an api error otherwise.
func (o *observer) fail(poll bool) {
if poll {
o.r.connectFailures.Add(1)
} else {
o.r.apiErrors.Add(1)
}
}
// backoff waits out a 429's Retry-After (bounded, cancellable). It buffers the response body first so
// the wait holds no connection open and the Bot API library can still parse the 429 afterwards.
func (o *observer) backoff(ctx context.Context, resp *http.Response) {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
d := retryAfter(resp.Header.Get("Retry-After"), body)
if d <= 0 {
return
}
if d > maxBackoff {
d = maxBackoff
}
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// retryAfter resolves the 429 back-off from the Retry-After header, falling back to the Bot API
// body's parameters.retry_after (both are seconds). It returns 0 when neither gives a positive value.
func retryAfter(header string, body []byte) time.Duration {
if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
var payload struct {
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
if err := json.Unmarshal(body, &payload); err == nil && payload.Parameters.RetryAfter > 0 {
return time.Duration(payload.Parameters.RetryAfter) * time.Second
}
return 0
}
@@ -1,121 +0,0 @@
package health
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeDoer returns a canned response and error for every request.
type fakeDoer struct {
resp *http.Response
err error
}
func (f *fakeDoer) Do(*http.Request) (*http.Response, error) { return f.resp, f.err }
// mkResp builds a response with a status, optional Retry-After header and body.
func mkResp(status int, retryAfter, body string) *http.Response {
h := http.Header{}
if retryAfter != "" {
h.Set("Retry-After", retryAfter)
}
return &http.Response{StatusCode: status, Header: h, Body: io.NopCloser(strings.NewReader(body))}
}
// run sends one request for the Bot API method through a fresh observer and returns the reporter's
// resulting snapshot. The 429 cases carry no Retry-After, so the back-off returns before any wait.
func run(method string, resp *http.Response, err error) *Reporter {
r := NewReporter()
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/"+method, nil)
_, _ = r.Wrap(&fakeDoer{resp: resp, err: err}).Do(req)
return r
}
func TestObserverClassifies(t *testing.T) {
cases := []struct {
name string
method string
resp *http.Response
err error
wantConnect uint64
wantAPI uint64
wantRate uint64
wantLastOKSet bool
}{
{"poll ok stamps liveness", "getUpdates", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
{"send ok stamps liveness", "sendMessage", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
{"poll 5xx is a connect failure", "getUpdates", mkResp(502, "", ""), nil, 1, 0, 0, false},
{"send 5xx is an api error", "sendMessage", mkResp(500, "", ""), nil, 0, 1, 0, false},
{"429 is rate limited, not an error", "sendMessage", mkResp(429, "", `{"ok":false}`), nil, 0, 0, 1, false},
{"4xx other than 429 is not counted", "sendMessage", mkResp(403, "", ""), nil, 0, 0, 0, false},
{"poll transport error is a connect failure", "getUpdates", nil, errors.New("dial"), 1, 0, 0, false},
{"send transport error is an api error", "sendMessage", nil, errors.New("dial"), 0, 1, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := run(tc.method, tc.resp, tc.err).Snapshot()
if s.GetConnectFailures() != tc.wantConnect || s.GetApiErrors() != tc.wantAPI || s.GetRateLimited() != tc.wantRate {
t.Errorf("counters = connect %d api %d rate %d, want %d/%d/%d",
s.GetConnectFailures(), s.GetApiErrors(), s.GetRateLimited(), tc.wantConnect, tc.wantAPI, tc.wantRate)
}
if (s.GetLastOkUnix() > 0) != tc.wantLastOKSet {
t.Errorf("last_ok set = %v, want %v", s.GetLastOkUnix() > 0, tc.wantLastOKSet)
}
})
}
}
// TestObserverIgnoresShutdownTransportError checks a transport error under a cancelled context (a
// shutdown) is not counted as a Bot API failure.
func TestObserverIgnoresShutdownTransportError(t *testing.T) {
r := NewReporter()
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/getUpdates", nil)
ctx, cancel := context.WithCancel(req.Context())
cancel()
_, _ = r.Wrap(&fakeDoer{err: errors.New("dial")}).Do(req.WithContext(ctx))
if s := r.Snapshot(); s.GetConnectFailures() != 0 {
t.Errorf("shutdown transport error must not count, got connect=%d", s.GetConnectFailures())
}
}
func TestRetryAfter(t *testing.T) {
cases := []struct {
header string
body string
want time.Duration
}{
{"5", "", 5 * time.Second},
{"", `{"parameters":{"retry_after":7}}`, 7 * time.Second},
{"3", `{"parameters":{"retry_after":9}}`, 3 * time.Second}, // header wins
{"", `{"ok":false}`, 0},
{"0", "", 0},
{"", "not json", 0},
}
for _, tc := range cases {
if got := retryAfter(tc.header, []byte(tc.body)); got != tc.want {
t.Errorf("retryAfter(%q, %q) = %v, want %v", tc.header, tc.body, got, tc.want)
}
}
}
// TestSnapshotCommit checks Commit clears only the sent deltas, so counts accrued between the
// snapshot and the send survive into the next report.
func TestSnapshotCommit(t *testing.T) {
r := NewReporter()
r.apiErrors.Add(3)
snap := r.Snapshot()
if snap.GetApiErrors() != 3 {
t.Fatalf("snapshot api_errors = %d, want 3", snap.GetApiErrors())
}
r.apiErrors.Add(2) // accrues after the snapshot
r.Commit(snap)
if got := r.Snapshot().GetApiErrors(); got != 2 {
t.Errorf("after commit api_errors = %d, want 2 (the post-snapshot accrual)", got)
}
}
+1 -4
View File
@@ -23,10 +23,7 @@ ENV APP_VERSION=${VERSION}
WORKDIR /app
COPY --from=build /src/renderer/node_modules ./node_modules
COPY --from=build /src/renderer/dist ./dist
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
# price list is fetched from the backend at request time and spliced in.
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
COPY renderer/src/server.mjs renderer/src/render.mjs ./src/
USER node
EXPOSE 8090
CMD ["node", "src/server.mjs"]
+12 -22
View File
@@ -1,10 +1,10 @@
# renderer — the render sidecar (finished-game image + public offer page)
# renderer — the finished-game image-render sidecar
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
signed off) and the public **offer page**.
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image
build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on
[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is
pixel-identical to the design the owner signed off in the browser.
## Interface
@@ -12,30 +12,20 @@ signed off) and the public **offer page**.
`image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`);
`alphabet` is the per-variant `(index, letter, value)` table tile values are drawn
from; `labels` localizes the non-play moves (pass/exchange/resign/timeout);
`hostname` + `dateLocale` feed the footer. Internal-only.
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
`hostname` + `dateLocale` feed the footer.
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
The service renders and nothing else: for the PNG, authentication, the participant check and the
signed public download URL all live in the backend (`backend/internal/server/export.go`), the path
being client → gateway `/dl/*` → backend → this sidecar; for the offer, the catalog projection and
its cache live in the backend (`backend/internal/payments/offer.go`) and no user input reaches the
page.
The service draws and nothing else: authentication, the participant check and the signed
public download URL all live in the backend (`backend/internal/server/export.go`); the
network path is client → gateway `/dl/*` → backend → this sidecar.
## Development
```sh
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
# (allowBuilds) or the native binary is silently never fetched
pnpm test # bundles, then node --test (PNG smoke + offer splice)
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
# reachable backend (else the boot read / the price fetch fail):
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
pnpm test # bundles, then node --test against testdata/request.json
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
```
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
-4
View File
@@ -9,9 +9,5 @@ await build({
format: 'esm',
platform: 'node',
outfile: 'dist/gameimage.mjs',
// The shared ui/src/lib modules are copied without their node_modules, so a bare npm import they
// make (offer.ts → 'marked', the offer renderer's markdown parser) is not resolvable from the ui
// tree. NODE_PATH-style fallback to the renderer's own node_modules, where marked is a dependency.
nodePaths: ['node_modules'],
logLevel: 'info',
});
-1
View File
@@ -9,7 +9,6 @@
"test": "pnpm run bundle && node --test test/*.test.mjs"
},
"dependencies": {
"marked": "^18.0.5",
"skia-canvas": "^3.0.6"
},
"devDependencies": {
-10
View File
@@ -8,9 +8,6 @@ importers:
.:
dependencies:
marked:
specifier: ^18.0.5
version: 18.0.6
skia-canvas:
specifier: ^3.0.6
version: 3.0.8
@@ -212,11 +209,6 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
marked@18.0.6:
resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==}
engines: {node: '>= 20'}
hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -355,8 +347,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
marked@18.0.6: {}
ms@2.1.3: {}
parenthesis@3.1.8: {}
+4 -7
View File
@@ -1,9 +1,6 @@
// The esbuild bundle entry: re-exports the SHARED ui/src/lib modules — the exact modules the
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
// used to invoke, now server-side so the live catalog price list can be spliced in.
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet';
export { renderOfferHtml } from '../../ui/src/lib/offer';
-17
View File
@@ -1,17 +0,0 @@
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
import { renderOfferHtml } from '../dist/gameimage.mjs';
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
// tables before rendering.
export const pricingMarker = '<#pricing_template#>';
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
export function renderOffer(offerMarkdown, pricingTables) {
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
}
+8 -43
View File
@@ -1,43 +1,20 @@
// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
// finished-game export image. It runs the same drawGameImage the browser build ships
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
//
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here)
// GET /offer → 301 /offer/
// GET /healthz → 200
//
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
// markdown; no user input reaches it.
// The service is internal-only (docker network `internal`); authentication, participant
// checks and the signed public URL all live in the backend — this process only draws.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { renderRequest } from './render.mjs';
import { renderOffer } from './offer.mjs';
const PORT = Number(process.env.RENDERER_PORT || 8090);
// A render request is a finished game's journal — generously capped.
const MAX_BODY = 2 * 1024 * 1024;
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked
// path for a local run (point it at ../ui/legal/offer_ru.md).
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8');
const OFFER_PRICING_URL =
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
// fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a
// 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list.
async function fetchOfferPricing() {
const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) {
throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 });
}
return resp.text();
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
@@ -58,23 +35,11 @@ function readBody(req) {
const server = createServer(async (req, res) => {
try {
const path = (req.url || '').split('?')[0];
if (req.method === 'GET' && path === '/healthz') {
if (req.method === 'GET' && req.url === '/healthz') {
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
return;
}
if (req.method === 'GET' && path === '/offer') {
res.writeHead(301, { location: '/offer/' }).end();
return;
}
if (req.method === 'GET' && path === '/offer/') {
const html = renderOffer(OFFER_MD, await fetchOfferPricing());
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(html);
return;
}
if (req.method === 'POST' && path === '/render') {
if (req.method === 'POST' && req.url === '/render') {
const png = await renderRequest(JSON.parse(await readBody(req)));
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
return;
-29
View File
@@ -1,29 +0,0 @@
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts);
// here we assert the substitution and that the tables reach the rendered HTML.
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderOffer, pricingMarker } from '../src/offer.mjs';
const tables =
'| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n' +
'| --- | --- | --- | --- |\n' +
'| 50 «Фишек» | 200.00 | 30 | 100 |';
test('splices the price list into the offer and renders the tables to HTML', () => {
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
const html = renderOffer(md, tables);
// The marker is gone and the projected table reached the document as a real HTML table.
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted');
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
// The offer chrome from the shared renderer is intact.
assert.ok(html.includes('<!doctype html>'));
});
test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |');
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
});
-101
View File
@@ -1,101 +0,0 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files
# Release signing material must never be committed (see ANDROID_PLAN.md — keystore loss/leak risk).
*.jks
*.keystore
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Cordova plugins for Capacitor
capacitor-cordova-android-plugins
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
-2
View File
@@ -1,2 +0,0 @@
/build/*
!/build/.npmkeep
-54
View File
@@ -1,54 +0,0 @@
apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
-19
View File
@@ -1,19 +0,0 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
-21
View File
@@ -1,21 +0,0 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -1,26 +0,0 @@
package com.getcapacitor.myapp;
import static org.junit.Assert.*;
import android.content.Context;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumented test, which will execute on an Android device.
*
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
*/
@RunWith(AndroidJUnit4.class)
public class ExampleInstrumentedTest {
@Test
public void useAppContext() throws Exception {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
}
}
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
@@ -1,5 +0,0 @@
package ru.eruditgame.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

@@ -1,34 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillType="evenOdd"
android:pathData="M32,64C32,64 38.39,52.99 44.13,50.95C51.37,48.37 70.14,49.57 70.14,49.57L108.26,87.69L108,109.01L75.97,107.97L32,64Z"
android:strokeColor="#00000000"
android:strokeWidth="1">
<aapt:attr name="android:fillColor">
<gradient
android:endX="78.5885"
android:endY="90.9159"
android:startX="48.7653"
android:startY="61.0927"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M66.94,46.02L66.94,46.02C72.44,50.07 76,56.61 76,64L32,64C32,56.61 35.56,50.11 40.98,46.06L36.18,41.19C35.45,40.45 35.45,39.3 36.18,38.56C36.91,37.81 38.05,37.81 38.78,38.56L44.25,44.05C47.18,42.57 50.48,41.71 54,41.71C57.48,41.71 60.78,42.57 63.68,44.05L69.11,38.56C69.84,37.81 70.98,37.81 71.71,38.56C72.44,39.3 72.44,40.45 71.71,41.19L66.94,46.02ZM62.94,56.92C64.08,56.92 65,56.01 65,54.88C65,53.76 64.08,52.85 62.94,52.85C61.8,52.85 60.88,53.76 60.88,54.88C60.88,56.01 61.8,56.92 62.94,56.92ZM45.06,56.92C46.2,56.92 47.13,56.01 47.13,54.88C47.13,53.76 46.2,52.85 45.06,52.85C43.92,52.85 43,53.76 43,54.88C43,56.01 43.92,56.92 45.06,56.92Z"
android:strokeColor="#00000000"
android:strokeWidth="1" />
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportHeight="108"
android:viewportWidth="108">
<path
android:fillColor="#26A69A"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF"
android:strokeWidth="0.8" />
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<WebView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background>
<inset android:drawable="@mipmap/ic_launcher_background" android:inset="16.7%" />
</background>
<foreground>
<inset android:drawable="@mipmap/ic_launcher_foreground" android:inset="16.7%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

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