Compare commits

..

25 Commits

Author SHA1 Message Date
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 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
184 changed files with 993 additions and 10338 deletions
-242
View File
@@ -1,242 +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). Browsers/WebView are cached, so a
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
## Wire / schema evolution (client ↔ gateway ↔ backend)
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
and breaks older readers.
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
both the FBS schema and the backend DTO.
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
DTOs. Change both or they drift.
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
then bumps it from the live event. Don't expect it on the shared broadcast.
## UI / Svelte 5
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
subscription. Rename (e.g. `view`).
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
`{' : '}`.
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
tokens (mirror `NewGame`'s `.invite`).
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
with `$state.snapshot(...)` before storing.
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
client-generated file by **copying it to the clipboard**.
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
(routes to `vk.com/away.php`). Verify on-device.
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
matrix; do not re-litigate it without new on-device facts.
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
glyph fallback for old rendering.
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
those live on the deployed contour, not in the e2e.
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
Subscribe stream, cosmetic, deliberately left as-is.
## Testing
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
intercepts Playwright taps.
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
eyeball per-variant tiles.
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
plugin-config gotcha in wiring them).
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
Use **testcontainers** for container-backed tests.
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
or the service crash-loops on start.
## Deploy / test contour (operational)
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
PR, one deploy) so deploys don't clobber each other.
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
prerelease step in `PRERELEASE.md`.
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
locale.
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
bot-link silently dies. Diagnose from inside the netns.
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
socket-inspect trick (single-service recreate).
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
caddy; don't trust "deploy green" for an edge-config change.
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
to the landing catch-all. Add a CI probe for the route.
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
telemetry log or Tempo, not caddy.
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
`/assets/main-*.js`.
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
their pinned version. The **owner** does the upload.
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
re-run, it's not your code.
## Repo workflow
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
/ add a plan line — not file a tracker issue.
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
stale-mergeable trap (re-check mergeability right before merging).
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
case — watch the post-merge runs too.
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
local feature branch.
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
wedged contour can be recovered by recreating the host container set.
## Domain semantics (not obvious from the code)
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
owner on every deletion point before wiring it.
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
account row is created at the **code-request** step, not at confirmation.
- **The robot has two distinct time windows:** a **sleep window** (~00:0007:00, gates its moves and
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
**sleep** window.
## Production topology
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
the source of truth for IPs/roles).
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
owner-side step **before** a deploy, not after.
## Feature-area pointers (state lives in the linked docs/plans, not here)
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0E9). go-jet money rule above applies.
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
`--pg1-user=scrabble`.
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
offline-first bundles the dictionaries (see `ANDROID_PLAN.md`).
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
App, server-side confidential code exchange.
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
offline-first, RuStore).
@@ -1,172 +0,0 @@
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
#
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
# runner host needs nothing pre-installed.
name: android-build
run-name: "android build ${{ github.sha }}"
on:
workflow_dispatch:
inputs:
confirm:
description: 'Type "build" to confirm an APK build from master.'
required: true
default: ""
permissions:
contents: read
env:
NO_COLOR: "1"
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
DICT_VERSION: ${{ vars.DICT_VERSION }}
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
VITE_PAYMENTS_DISABLED: "1"
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
# lives elsewhere (the default matches deploy/README.md's install path).
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
jobs:
build:
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
# missing — this doubles as the runner-access check (deploy/README.md).
- name: Verify the host Android SDK
run: |
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$sm" ]; then
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
fi
for pkg in "platforms/android-36" "build-tools"; do
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
fi
done
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
- name: Compute version + native build env
id: prep
env:
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
run: |
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
# rather than derive a versionCode from a "-N-gSHA" describe.
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
case "$desc" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
esac
v="${desc#v}"
IFS=. read -r MA MI PA <<< "$v"
# 10# forces base-10 so a zero-padded part is never read as octal.
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
# trailing slash so the Connect endpoint never doubles it.
gateway="${PUBLIC_BASE_URL%/}"
{
echo "tag=$desc"
echo "name=$v"
echo "code=$code"
echo "gateway=$gateway"
} >> "$GITHUB_OUTPUT"
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
ls -la "${GITHUB_WORKSPACE}/dawg"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Build the SPA (native flavour)
working-directory: ui
env:
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
run: pnpm run build
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
# (after the build, before cap sync copies dist/ into the native assets).
- name: Bundle the dictionaries
working-directory: ui
env:
DICT_DIR: ${{ github.workspace }}/dawg
run: node scripts/bundle-dicts.mjs
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
- name: Sync the native project
working-directory: ui
run: node_modules/.bin/cap sync android
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
echo "keystore decoded -> signed release build"
else
echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
uses: actions/upload-artifact@v4
with:
name: erudit-${{ steps.prep.outputs.name }}-apk
path: ui/android/app/build/outputs/apk/release/*.apk
if-no-files-found: error
+4 -49
View File
@@ -353,11 +353,6 @@ jobs:
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
@@ -522,21 +517,10 @@ jobs:
# (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.
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
# cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
# now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
# the same deploy.
ok=
for i in $(seq 1 20); do
out="$(docker run --rm --network edge alpine:3.20 wget -q -T 10 -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"
ok=1
break
fi
sleep 3
done
if [ -z "$ok" ]; then
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"
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
@@ -544,35 +528,6 @@ jobs:
exit 1
fi
- name: Probe the /privacy/ and /eula/ legal pages are served
run: |
set -u
# /privacy/ and /eula/ are static legal markdown rendered by the render sidecar. If the
# @legal caddy route is missing they fall to the landing shell (also 200, canonical
# erudit-game.ru/), so assert the page-specific canonical URL — it uniquely identifies the
# rendered legal doc reaching the edge, not the landing catch-all. Read only the <head>
# (head -c 4096; the canonical link sits in the first bytes): the check is then
# size-independent, so the large EULA page does not exceed the timeout while the monitoring
# stack is still booting post-deploy and starving the CPU-capped renderer. Retry like the
# landing/gateway/backend probe to ride out that window.
for route in privacy eula; do
ok=
for i in $(seq 1 20); do
head_out="$(docker run --rm --network edge alpine:3.20 sh -c "wget -q -T 10 -O - http://scrabble/${route}/ 2>/dev/null | head -c 4096" || true)"
if printf '%s' "$head_out" | grep -qF "erudit-game.ru/${route}/"; then
echo "ok: /${route}/ serves the rendered legal page"
ok=1
break
fi
sleep 3
done
if [ -z "$ok" ]; then
echo "FAIL: /${route}/ did not serve the rendered legal page (@legal route missing?)"
docker logs --tail 50 scrabble-renderer || true
exit 1
fi
done
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
-6
View File
@@ -112,12 +112,6 @@ jobs:
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
# Client-version gate (ARCHITECTURE.md §2): plain (unprefixed) vars, shared across contours
# (empty ⇒ dormant). On prod the stamped client version is a real semver, so the gate enforces
# here — set GATEWAY_MIN_CLIENT_VERSION to the release in the same rollout that ships a breaking
# wire change; bump GATEWAY_RECOMMENDED_CLIENT_VERSION (≥ min) to nudge upgrades softly.
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
-1115
View File
File diff suppressed because it is too large Load Diff
-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
@@ -115,13 +115,6 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
# variables, shared across contours; in the test contour the stamped client version is a commit hash
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
GATEWAY_MIN_CLIENT_VERSION=
GATEWAY_RECOMMENDED_CLIENT_VERSION=
# --- VK Mini App ------------------------------------------------------------
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
-28
View File
@@ -118,8 +118,6 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -238,32 +236,6 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
that survives losing the host.
## Android app build & release (RuStore)
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
**unsigned** release APK otherwise (a dry run still proves the pipeline).
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned**
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
the SDK is missing or unreadable.
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
variable to the store listing once published (empty until then — the in-app update button no-ops).
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
away cleanly instead of failing blind.
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
## Point-in-time recovery (PITR)
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
+6 -7
View File
@@ -107,13 +107,12 @@
}
}
# The public legal pages are rendered by the render sidecar: the offer (with the live catalog
# price list from the backend spliced into ui/legal/offer_ru.md), the privacy policy and the EULA
# (both static markdown). Only these paths are 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 them.
@legal path /offer /offer/* /privacy /privacy/* /eula /eula/*
handle @legal {
# 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
}
-6
View File
@@ -222,12 +222,6 @@ services:
GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
# Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
-2
View File
@@ -49,8 +49,6 @@ export CADDY_SITE_ADDRESS='$CADDY_SITE_ADDRESS'
export LOG_LEVEL='${LOG_LEVEL:-info}'
export DICT_VERSION='$DICT_VERSION'
export APP_VERSION='$APP_VERSION'
export GATEWAY_MIN_CLIENT_VERSION='$GATEWAY_MIN_CLIENT_VERSION'
export GATEWAY_RECOMMENDED_CLIENT_VERSION='$GATEWAY_RECOMMENDED_CLIENT_VERSION'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
+44 -120
View File
@@ -100,26 +100,15 @@ dropped). Horizontal scaling is explicit future work.
auth operations are unauthenticated and return the minted token. A unary
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
200); only edge failures (rate limit, missing session, unknown type, internal)
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
via a single **net-state machine** a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
local lobby, no transport kill switch and no device-local create paths.
surface as Connect error codes. The client treats a connectivity edge failure as
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
response was lost — its button is disabled while offline and the player re-issues it on
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
**Edge hardening:** every request body on the public listener is capped at
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
@@ -151,40 +140,6 @@ dropped). Horizontal scaling is explicit future work.
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
client's in-app stream while the app is open. Out-of-app delivery uses
platform-native push via the platform side-service.
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
vars empty ⇒ off, web behaviour unchanged).
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
`offlineVersionLocked` (offline mode) and shows a **dismissable notice****"Update"** (native → the
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **`min`**
(validated at config load); empty ⇒ the soft tier is off.
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
rollout (see [`../deploy/README.md`](../deploy/README.md)).
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
rather than interrupting local play.
## 3. Authentication & sessions
@@ -269,19 +224,6 @@ arrive from a platform rather than completing a mandatory registration).
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
Platform and email users are auto-provisioned **durable** accounts with an
identity.
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
prior online-session rule.
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
> two-bot model (one bot per service language, with `accounts.service_language`, a
@@ -967,14 +909,10 @@ 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 legal pages** — the offer at `/offer/`, the privacy
policy at `/privacy/` and the EULA at `/eula/` — the edge-exposed routes on it (caddy routes them
here; its `/render` stays internal). All three reuse the shared `ui/src/lib/offer.ts`
`renderLegalHtml` (one renderer, no drift) over their owner-edited `ui/legal/*_{ru,en}.md` — each is
**bilingual (RU + EN)** with a client-side language + theme switcher (default Russian, persisted;
the offer's EN view transliterates the Russian product names) — baked into the image. `/privacy/`
and `/eula/` are static; the offer additionally splices in the **live price list** (§4.4) into both
languages: it
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
@@ -1394,18 +1332,13 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
cache-first — the old build's version until a second load. The landing page and the conditional
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
hotseat create is guarded when
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
the in-browser engine and driven by the same game screen, the robot replying locally.
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
creates one through the in-browser engine — the same game screen then drives it, the robot replying
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
@@ -1433,27 +1366,36 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
mode** boots from the persisted session and
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
while offline. The gateway registers the `.webmanifest` MIME type
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the public
legal pages `/offer/`, `/privacy/` and `/eula/` (rendered by the `renderer` sidecar the offer with
the live catalog price list spliced in) go to the render sidecar; and the catch-all — notably the
landing at `/` — goes to the landing
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/`
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
container. The
**Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
@@ -1526,24 +1468,6 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
contour activates; the prod main host omits it.
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
the cache). It is **offline-first**:
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook:
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
## 14. CI & branches
- **Two long-lived branches**: **`development`** is the integration
+31 -58
View File
@@ -30,13 +30,11 @@ render with arbitrary languages, so detection would make the indexed content
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page. The footer links to the three **legal
documents** — the **user agreement** (`/eula/`), the **privacy policy** (`/privacy/`) and the
**public offer** (`/offer/`) — and, beside them, **feedback** (the Telegram bot the documents name as
the seller's contact). Each is **bilingual (RU/EN)** with a language + theme switcher and is rendered
from its `ui/legal/*_{ru,en}.md` sources by the render sidecar; the offer additionally splices in 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. The footer links to the **public
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
the published prices always match what is currently on sale — no redeploy to update them.
On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
@@ -279,28 +277,22 @@ add-friend are off. AI games are **practice** — they never count toward a play
statistics.
### Offline mode
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
native apps only). When it cannot reach the
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
app never touches the network.
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
A New Game against the robot creates a device-local **vs_ai** game — it
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
offline mode, and never sync to the account. So a game can be created and played with no connection,
the app quietly preloads the dictionaries for the player's enabled variants while still online, and
(once installed) launches from a precached shell even with no network. Switching **to** offline first
checks that the enabled variants' dictionaries are on the device — if any are missing it fetches them
and waits briefly, and if they cannot be readied it stays online with a short *needs internet* note
(the download keeps running in the background, so a later switch is instant). The mode is
device-scoped and sticky across launches.
The **lobby stays unified**: your device-local games are active, while your server games are still
listed but **greyed out** — visible but un-openable until you are back online (a tap explains why).
Online-only surfaces (the Stats tab) are disabled and invitations are hidden while offline. A New Game
against the robot creates a device-local **vs_ai** game that plays entirely on the device with no
backend; local games are kept on the device and never sync to the account, so a game can be created and
played with no connection. The app quietly preloads the dictionaries for the player's enabled variants
while still online, and (once installed, or on the native app) launches from bundled/precached data even
with no network; if a variant's dictionary is not available offline, creating that game is disabled with
a short note.
New Game's **with friends** offers a choice — **invite a friend** to play online, or a **local
pass-and-play** game for 2-4 people sharing one device; with no network only pass-and-play is available.
(In the online-only Telegram/VK mini-apps *with friends* is the friend invite alone — no pass-and-play.)
In pass-and-play you first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
keypad; until it is set the player rows stay locked. The app then asks whether you are playing too —
if so you take the first seat with your profile name. You name each player (2 to 4; add or remove
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
@@ -313,31 +305,14 @@ pass-and-play game. A game that finishes normally is saved to the offline lobby
deleting any pass-and-play game — running or finished — from the lobby asks for the leader password,
so no one can wipe a game the moment they have moved.
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
offline, so you are never interrupted for a hiccup.
### Staying up to date
When a new client version is required, the app does not lock you out. On the **native** app a
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
playing your on-device games); on the web it offers **Update** (reload) instead. A softer,
non-blocking **"update available"** banner appears in the lobby when a newer — but not yet
required — version exists; you can update or dismiss it, and play continues either way.
### Native app (Android)
The game also ships as a standalone **Android app** (first on **RuStore**), a packaged build of the same
client. Everything it needs is bundled, so it opens with **no network**: a first launch offline drops you
straight into the lobby as a **guest** (no sign-in wall) and you can play right away — against the robot, or
a **pass-and-play** game with friends on one device — using the dictionaries shipped inside the app. When the
network is available the app quietly establishes a guest in the background and online features (random
opponents, friends, statistics) light up without interrupting play; local games you started offline stay on
the device. To keep a durable account you sign in with **email** from Profile (the guest becomes your
account); Telegram and VK sign-in are not offered in the native app. In-app purchases are hidden in this
first release. Because an installed app can be far older than the server, a build that is **too old** to talk
to the current server shows a single, non-dismissable *update* screen whose button opens the store listing —
this appears only on an online action, never while you are playing offline.
The app also **enters offline mode on its own** when it cannot reach the network. On a cold launch
with no connection it switches to offline for that session; when the device is online but the gateway
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
the network — airplane mode — switches to offline automatically, and restoring it returns online by
itself. A self-entered offline is temporary: it reverts to online the moment the network is back, and
the next launch re-checks. A **deliberate** offline — the Settings toggle, or **Switch** in that
dialog — is the player's choice: it is kept, and never undone automatically.
### Social: friends, block, chat, nudge
Become friends in two ways: redeem a **one-time code** the other player issues (six
@@ -432,11 +407,9 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th
barred from feedback (a role, not a full account block) sees the send control disabled.
### Telegram support chat
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
with the support desk's working hours and what to include (a description and, when possible,
screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
user sees no automatic reply to a forwarded message; an
A user can also reach the operators straight from the Telegram bot: anything they send the bot
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
private support group, grouped into a per-user thread. The user sees no automatic reply; an
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
+32 -58
View File
@@ -32,13 +32,12 @@ top-1 подсказку, безлимитную проверку слова с
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница. В подвале — ссылки на три **юридических
документа** — **пользовательское соглашение** (`/eula/`), **политику конфиденциальности**
(`/privacy/`) и **публичную оферту** (`/offer/`) — и рядом на **обратную связь** (тот самый
Telegram-бот, который документы указывают как контакт Продавца). Каждый — **двуязычный (RU/EN)** с
переключателем языка и темы, рендерится из исходников `ui/legal/*_{ru,en}.md` сайдкаром-рендерером;
оферта дополнительно подставляет **перечень стоимости** (§4.4), формируемый из живого каталога
товаров, поэтому опубликованные цены всегда совпадают с тем, что сейчас в продаже — без передеплоя.
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
сейчас в продаже — без передеплоя.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
@@ -284,27 +283,22 @@ e-mail) либо ввод фразы. Активные игры форфейтя
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
### Офлайн-режим
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
приложениям). Когда оно не может достучаться
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
сети.
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
сохраняется между запусками.
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 24 человек
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
@@ -318,32 +312,14 @@ e-mail) либо ввод фразы. Активные игры форфейтя
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
своего хода.
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
вас не прерывают из-за короткой икоты.
### Актуальная версия
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
### Нативное приложение (Android)
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
офлайн-игры.
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
### Социальное: друзья, блок, чат, nudge
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
@@ -442,11 +418,9 @@ Telegram. На сенсорном устройстве в настройках
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
Пользователь не получает автоответа на пересланное сообщение; оператор
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
+5 -37
View File
@@ -17,43 +17,21 @@ tests or touching CI.
- **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation, the GCG share/copy/download choice and the **net-state reducer**
(`netstate.test.ts` — every transition of the connectivity/version machine, the anti-flap
hysteresis, the two-tier version decision and all 12 offline edge cases as pure assertions),
plus Playwright specs against the
derivation and the GCG share/copy/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the export chooser's
finished-only visibility + its signed-URL download flow (route-intercepted). The
**offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game
end-to-end: offline is **implicit** now (no toggle), so it drives the mock's `__net` hook to
auto-detect offline (asserting the toast, the greyed-from-cache server games and the disabled
Stats tab), self-heals back online, then creates and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
end-to-end: it forces the installed-PWA display mode, enters offline through the
Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates
and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the
rack is deterministic and the human can tap out a precomputed opening), asserting the
robot's real reply and the IndexedDB replay after a reload. A local game needs a real
dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview
build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui`
CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to
the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the
production build. The **native offline-first** spec (`e2e/native.spec.ts`) injects
`window.androidBridge` (so `@capacitor/core` resolves the platform to `android` — a bare
`Capacitor.getPlatform` stub is clobbered by the core shim), then asserts a no-session cold boot lands
in the **offline guest lobby** (not `/login`), plays a local vs_ai move from the **bundled** dawg tier
(`playwright.config.ts` bundles the dicts into `dist-e2e/dict/`), starts a hotseat game, and drives
`window.__native.reconcile()` to prove online lights up — the device-local game stays listed in the
**unified lobby** (closing G-step-0) — and Profile hides the Telegram/VK link buttons.
The **version-gate** spec (`e2e/update.spec.ts`) drives the `__update` hook to prove both tiers — the
hard *Update / Play offline* notice (and that "Play offline" drops into the offline lobby) and the soft
dismissable *update available* nudge in the lobby; `retry.test.ts` covers the `FailedPrecondition →
update_required` mapping.
- **Client-version gate** (Go, two tiers) — `gateway/internal/clientver` unit-tests the `v?MAJOR.MINOR.PATCH`
parse (with/without `v`, a `-N-gSHA` suffix, `dev`/empty ⇒ !ok) and ordering. `connectsrv`'s server tests
assert the **hard** tier — a too-old `Execute` returns `result_code = "update_required"` (and the op
handler never ran), a too-old `Subscribe` returns `FailedPrecondition`, and an absent header / empty min /
unparseable header / equal version all pass (fail-open) — and the **soft** tier (`TestExecuteUpdateRecommended`):
a served build in `min ≤ v < recommended` carries the `X-Update-Recommended: 1` response header, while a
too-old, up-to-date, absent/garbled, or dormant-tier request does not. `config` tests reject a non-empty
unparseable `GATEWAY_MIN_CLIENT_VERSION` / `GATEWAY_RECOMMENDED_CLIENT_VERSION`, and a recommended below the
minimum.
production build.
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
real self-played 35-move game) must rasterize to a plausible PNG
@@ -197,16 +175,6 @@ tests or touching CI.
`inttest` drives the **feedback lifecycle** end to end: the guest / ban / pending gates, the reply
read + one-week visibility window, the account-role grant/revoke, and the admin queue filters with
delete / delete-all. The admin-console render test renders the feedback list + detail pages.
- **Native Android (manual on-device smoke)** — the packaged APK is verified by hand on a device /
emulator before release (the automated e2e covers the offline-first logic; WebView-specific chrome and
store behaviour are not reproducible in Playwright). Checklist: installs and cold-launches; in
**airplane mode** the cold launch lands in the **guest lobby**; play a local **vs_ai** move and a
**2-player hotseat** game with no network; the hardware **Back** button navigates then exits at the
root; a share / export link resolves to `erudit-game.ru` (not `file://`); turning the **network on**
lights up online play (a server guest is established); Profile offers **email** sign-in but no
Telegram / VK link; and with `GATEWAY_MIN_CLIENT_VERSION` bumped above the build, an online action
raises the terminal **update** overlay. Measure native chrome (safe-area / edge-to-edge) by CDP, not by
eyeballing a screenshot — an emulator WebView may be newer than a user's device (see `.claude/CLAUDE.md`).
## Principles
+3 -8
View File
@@ -42,15 +42,10 @@ ENV VITE_TELEGRAM_BOT_ID=$VITE_TELEGRAM_BOT_ID \
VITE_APP_VERSION=$VITE_APP_VERSION \
VITE_ADS_STUB=$VITE_ADS_STUB
# Install with the lockfile first, then build. Committed src/gen/ means no codegen here.
# --ignore-scripts: the Vite build needs no dependency build script — esbuild's platform binary
# ships as an optional dependency (only its CLI-shim postinstall is skipped, which Vite does not use),
# and core-js-bundle's prebuilt file is read directly. It notably skips sharp's native build (a
# `pnpm android:assets` tool via @capacitor/assets, unused here), which would otherwise fail on this
# Alpine/musl stage with no prebuilt binary and no compiler. The workspace allowBuilds stay for local
# installs (where android:assets does need sharp built).
# Install with the lockfile first (the workspace file carries pnpm's build-script
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile --ignore-scripts
RUN pnpm install --frozen-lockfile
COPY ui ./
RUN pnpm build
+16 -18
View File
@@ -221,24 +221,22 @@ 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,
RecommendedClientVersion: cfg.RecommendedClientVersion,
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,
})
// Bridge the backend push stream into the fan-out hub (and the out-of-app
-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 -38
View File
@@ -9,7 +9,6 @@ import (
"strings"
"time"
"scrabble/gateway/internal/clientver"
pkgtel "scrabble/pkg/telemetry"
)
@@ -55,16 +54,6 @@ type Config struct {
// MaxBodyBytes caps one inbound request body on the public listener and one
// Connect message read; oversized requests are refused without buffering.
MaxBodyBytes int
// MinClientVersion, when non-empty, is the lowest client version (MAJOR.MINOR.PATCH)
// the edge serves. A client reporting an older X-Client-Version is turned away with an
// "update required" signal before its payload is decoded. Empty leaves the gate dormant
// (every client is served) — the default for web-only deployments.
MinClientVersion string
// RecommendedClientVersion, when non-empty, is the version below which a served client is nudged
// to update — a non-blocking "update available" signal (the X-Update-Recommended response header)
// on gated responses; the call still succeeds. It must be at least MinClientVersion. Empty leaves
// the soft tier off (the default); it does not affect the hard MinClientVersion gate.
RecommendedClientVersion string
// RateLimit configures the in-memory anti-abuse limiter.
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
@@ -237,16 +226,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"),
RecommendedClientVersion: os.Getenv("GATEWAY_RECOMMENDED_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"),
@@ -345,23 +332,6 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
}
if c.MinClientVersion != "" {
if _, ok := clientver.Parse(c.MinClientVersion); !ok {
return fmt.Errorf("config: GATEWAY_MIN_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.MinClientVersion)
}
}
if c.RecommendedClientVersion != "" {
rec, ok := clientver.Parse(c.RecommendedClientVersion)
if !ok {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q is not a MAJOR.MINOR.PATCH version", c.RecommendedClientVersion)
}
// The soft tier must sit at or above the hard minimum: a recommended below the minimum is a
// misconfiguration (every client below min is already turned away). An empty/unparseable
// minimum imposes no lower bound, so the recommended may stand alone.
if min, ok := clientver.Parse(c.MinClientVersion); ok && clientver.Less(rec, min) {
return fmt.Errorf("config: GATEWAY_RECOMMENDED_CLIENT_VERSION %q must be >= GATEWAY_MIN_CLIENT_VERSION %q", c.RecommendedClientVersion, c.MinClientVersion)
}
}
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
-59
View File
@@ -47,65 +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")
}
}
// TestLoadRecommendedClientVersion verifies the soft-tier config: dormant (empty) by default, a
// parseable version accepted (standing alone with no minimum, or at/above one), an unparseable one
// rejected, and a recommended below the minimum rejected.
func TestLoadRecommendedClientVersion(t *testing.T) {
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.RecommendedClientVersion != "" {
t.Errorf("RecommendedClientVersion = %q, want empty (soft tier dormant)", c.RecommendedClientVersion)
}
// Stands alone with no minimum configured.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.20.0")
if c, err = Load(); err != nil {
t.Fatalf("Load with a valid recommended version (no min): %v", err)
}
if c.RecommendedClientVersion != "v1.20.0" {
t.Errorf("RecommendedClientVersion = %q, want %q", c.RecommendedClientVersion, "v1.20.0")
}
// At or above the minimum is accepted.
t.Setenv("GATEWAY_MIN_CLIENT_VERSION", "v1.16.0")
if _, err = Load(); err != nil {
t.Fatalf("Load with recommended >= min: %v", err)
}
// Below the minimum is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "v1.10.0")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for a recommended version below the minimum, got nil")
}
// Unparseable is rejected.
t.Setenv("GATEWAY_RECOMMENDED_CLIENT_VERSION", "dev")
if _, err := Load(); err == nil {
t.Fatal("Load: expected an error for an unparseable GATEWAY_RECOMMENDED_CLIENT_VERSION, got nil")
}
}
// TestLoadAbuseDefaults verifies the anti-abuse ban defaults: disabled (prod-only),
// the agreed thresholds, and no honeytoken.
func TestLoadAbuseDefaults(t *testing.T) {
-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 -124
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,21 +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". updateRecommendedHeader is the additive, non-blocking soft-tier signal: set
// on a served Execute response when the client is at or above the hard minimum but below the
// recommended version, it nudges an update without failing the call. Headers are the
// version-tolerant layer, so an old client simply ignores it. All part of the frozen wire
// contract (docs/ARCHITECTURE.md §2).
const (
clientVersionHeader = "X-Client-Version"
resultUpdateRequired = "update_required"
updateRecommendedHeader = "X-Update-Recommended"
)
// Limiter classes, the `class` attribute of gateway_rate_limited_total and the
// class field of the periodic rejection report.
const (
@@ -104,17 +88,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
// recClient is the version below which a served client is nudged to update (the soft tier);
// recOn is false when the soft tier is dormant (GATEWAY_RECOMMENDED_CLIENT_VERSION empty or
// unparseable). It is independent of the hard gate.
recClient clientver.Version
recOn bool
publicPolicy ratelimit.Policy
userPolicy ratelimit.Policy
emailPolicy ratelimit.Policy
@@ -155,13 +128,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
// RecommendedClientVersion is the version below which a served client is nudged to update (the
// non-blocking X-Update-Recommended header). Empty or unparseable leaves the soft tier dormant.
RecommendedClientVersion string
}
// NewServer constructs the edge service.
@@ -194,30 +160,6 @@ func NewServer(d Deps) *Server {
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
}
// Parse the minimum client version once. Config.validate already rejects an unparseable
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
// the gate dormant.
var minClient clientver.Version
gateOn := false
if d.MinClientVersion != "" {
if v, ok := clientver.Parse(d.MinClientVersion); ok {
minClient, gateOn = v, true
} else {
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
}
}
// Parse the recommended (soft-tier) version once, the same way. Config.validate already rejects an
// unparseable value or one below the minimum, so the warn branch only guards a direct (test)
// construction; an empty value leaves the soft tier dormant.
var recClient clientver.Version
recOn := false
if d.RecommendedClientVersion != "" {
if v, ok := clientver.Parse(d.RecommendedClientVersion); ok {
recClient, recOn = v, true
} else {
log.Warn("ignoring unparseable RecommendedClientVersion; update-recommended tier disabled", zap.String("value", d.RecommendedClientVersion))
}
}
return &Server{
registry: d.Registry,
sessions: d.Sessions,
@@ -234,10 +176,6 @@ func NewServer(d Deps) *Server {
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody,
minClient: minClient,
gateOn: gateOn,
recClient: recClient,
recOn: recOn,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
emailPolicy: ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst),
@@ -347,71 +285,15 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
})
}
// clientTooOld reports whether the X-Client-Version header names a version below the configured
// minimum. It fails open: with the gate dormant, or an absent or unparseable header, it returns
// false. The header is a client-controlled compatibility signal, not an access control (it is
// trivially spoofable), so a missing or garbled value — an old build predating the header, or a
// non-browser caller — must never be blocked spuriously.
func (s *Server) clientTooOld(header string) bool {
if !s.gateOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return clientver.Less(v, s.minClient)
}
// clientUpdateRecommended reports whether the X-Client-Version header names a version in the soft
// band — at or above the hard minimum but below the recommended version — so a served response can
// carry the non-blocking X-Update-Recommended nudge. It fails open exactly like clientTooOld: a
// dormant soft tier, or an absent or unparseable header, returns false. A client below the minimum is
// turned away by the hard gate and is never nudged, so it is excluded here too (a dormant hard gate
// leaves minClient at the zero version, which no real version is below, so the band is simply
// "below recommended").
func (s *Server) clientUpdateRecommended(header string) bool {
if !s.recOn {
return false
}
v, ok := clientver.Parse(header)
if !ok {
return false
}
return !clientver.Less(v, s.minClient) && clientver.Less(v, s.recClient)
}
// Execute runs one unary operation. Domain failures are returned in the envelope
// (result_code != "ok", HTTP 200); only edge failures (rate limit, missing
// session, unknown type, internal) become Connect errors.
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err error) {
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (*connect.Response[edgev1.ExecuteResponse], error) {
start := time.Now()
msgType := req.Msg.GetMessageType()
result := "internal"
defer func() { s.metrics.recordEdge(ctx, msgType, result, start) }()
// The soft-tier nudge rides a response header on any served response (the call still succeeds), so a
// client at or above the hard minimum but below the recommended version is told an update is
// available without being interrupted. A too-old client (turned away below) or a missing/garbled
// version yields no nudge.
recommend := s.clientUpdateRecommended(req.Header().Get(clientVersionHeader))
defer func() {
if recommend && resp != nil {
resp.Header().Set(updateRecommendedHeader, "1")
}
}()
// The version gate rides the outermost stable layer (an HTTP header) and is checked before
// the payload is decoded, so a too-old client makes zero successful calls but sees the
// recognizable update_required envelope rather than a decode crash (docs/ARCHITECTURE.md §2).
if s.clientTooOld(req.Header().Get(clientVersionHeader)) {
result = resultUpdateRequired
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: resultUpdateRequired,
}), nil
}
op, ok := s.registry.Lookup(msgType)
if !ok {
result = "unknown_type"
@@ -481,11 +363,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 -166
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,40 +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,
})
edgeSrv := httptest.NewServer(edge.HTTPHandler())
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
return client, func() {
edgeSrv.Close()
_ = backend.Close()
backendSrv.Close()
}
}
// newEdgeVersions is newEdgeMin with the soft-tier recommended version also armed (empty leaves it off).
func newEdgeVersions(t *testing.T, minVersion, recommendedVersion string, backendHandler http.HandlerFunc) (edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
if err != nil {
t.Fatalf("backendclient: %v", err)
}
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: transcode.NewRegistry(backend, nil),
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Hub: push.NewHub(0),
RateLimit: config.DefaultRateLimit(),
Heartbeat: 15 * time.Second,
MinClientVersion: minVersion,
RecommendedClientVersion: recommendedVersion,
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)
@@ -141,132 +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)
}
})
}
}
// TestExecuteUpdateRecommended verifies the soft-tier nudge on the unary path: a served client at or
// above the hard minimum but below the recommended version gets the X-Update-Recommended response
// header (the call still succeeds); a too-old (hard-gated), up-to-date, absent, or unparseable version
// and a dormant soft tier get no header.
func TestExecuteUpdateRecommended(t *testing.T) {
backend := func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
}
tests := []struct {
name string
min string
rec string
header string
wantResult string
wantHeader bool
}{
{"soft band is nudged", "v1.16.0", "v1.20.0", "v1.17.0", "ok", true},
{"at the minimum is nudged", "v1.16.0", "v1.20.0", "v1.16.0", "ok", true},
{"at the recommended is not nudged", "v1.16.0", "v1.20.0", "v1.20.0", "ok", false},
{"newer than recommended is not nudged", "v1.16.0", "v1.20.0", "v2.0.0", "ok", false},
{"too old is gated, not nudged", "v1.16.0", "v1.20.0", "v1.0.0", "update_required", false},
{"absent header, no nudge", "v1.16.0", "v1.20.0", "", "ok", false},
{"unparseable header, no nudge", "v1.16.0", "v1.20.0", "dev", "ok", false},
{"recommended alone (no min) nudges below it", "", "v1.20.0", "v1.0.0", "ok", true},
{"soft tier dormant, no nudge", "v1.16.0", "", "v1.0.0", "update_required", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeVersions(t, tc.min, tc.rec, backend)
defer cleanup()
req := connect.NewRequest(&edgev1.ExecuteRequest{MessageType: transcode.MsgAuthGuest, RequestId: "rq"})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
resp, err := client.Execute(context.Background(), req)
if err != nil {
t.Fatalf("execute: %v", err)
}
if got := resp.Msg.GetResultCode(); got != tc.wantResult {
t.Fatalf("result_code = %q, want %q", got, tc.wantResult)
}
if got := resp.Header().Get("X-Update-Recommended") == "1"; got != tc.wantHeader {
t.Fatalf("X-Update-Recommended present = %v, want %v", got, tc.wantHeader)
}
})
}
}
// TestSubscribeVersionGate verifies the streaming path: a too-old client is refused with
// FailedPrecondition, while an equal or absent version is admitted and proceeds to auth (which
// fails Unauthenticated with no session, proving it passed the version gate).
func TestSubscribeVersionGate(t *testing.T) {
noBackend := func(_ http.ResponseWriter, _ *http.Request) {}
tests := []struct {
name string
min string
header string
want connect.Code
}{
{"too old refused", "v1.16.0", "v1.0.0", connect.CodeFailedPrecondition},
{"equal admitted then auth-gated", "v1.16.0", "v1.16.0", connect.CodeUnauthenticated},
{"gate dormant admits", "", "v1.0.0", connect.CodeUnauthenticated},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
client, cleanup := newEdgeMin(t, tc.min, noBackend)
defer cleanup()
req := connect.NewRequest(&edgev1.SubscribeRequest{})
if tc.header != "" {
req.Header().Set("X-Client-Version", tc.header)
}
stream, err := client.Subscribe(context.Background(), req)
if err == nil {
for stream.Receive() {
}
err = stream.Err()
}
if got := connect.CodeOf(err); got != tc.want {
t.Fatalf("code = %v, want %v", got, tc.want)
}
})
}
}
func TestExecuteAuthedRequiresSession(t *testing.T) {
client, cleanup := newEdge(t, func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called without a session")
+3 -12
View File
@@ -9,9 +9,8 @@ it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/
validation gRPC API the gateway calls during Telegram auth, on the trusted internal
network with no VPN. Because it needs no Telegram reachability, **game login stays up
even when the bot or the bot-link is down**.
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start`
deep-links, the `/support` info reply) and `sendMessage`, the only component reaching
the Telegram Bot API. It
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch + `/start`
deep-links) and `sendMessage`, the only component reaching the Telegram Bot API. It
holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and
executes the send commands the gateway pushes, so its egress can run on a host with
native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
@@ -50,13 +49,6 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
chat" — rather than a dangling "@"). This is otherwise **self-contained**
— the bot never calls back into the game, so `/start` onboarding works even when the game
is down.
- **Support command.** `/support` replies with a fixed support-desk info message — the
operators' working hours and what to include (a description and, when possible,
screenshots). Like `/start` it answers only in a private chat and is **Russian or
English** by the sender's reported language, and it is listed in the bot's command menu
(localized, Russian/English). It is a dedicated command handler, so it intercepts
`/support` before the support relay below — the command line itself is not forwarded to
operators, while the user's following description still is.
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
group, the bot gates who may write there. The group **allows sending by default** (a
human setting) and the bot only **restricts** — Telegram intersects the chat default with
@@ -84,8 +76,7 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
message. State (user→topic map, block list, relayed ids) is a JSON file under
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic
reply (the `/support` command above aside).
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply.
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
+2 -19
View File
@@ -1,7 +1,6 @@
// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it
// runs the long-poll update loop — replying to /start (with an optional deep-link
// payload), to /support with the support-desk info message, and to any other message
// with a Mini App launch button — and sends the
// payload) and any other message with a Mini App launch button — and sends the
// notification and admin messages the connector requests. The bot token lives only
// in this process.
package bot
@@ -137,7 +136,6 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
tgbot.WithMessageTextHandler("/support", tgbot.MatchTypePrefix, t.handleSupport),
}
if t.supportEnabled() {
t.supportLocks = newKeyedMutex()
@@ -188,26 +186,11 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// Run sets the bot commands and the Mini App menu button, then blocks on the
// long-poll update loop until ctx is cancelled.
func (t *Bot) Run(ctx context.Context) {
// Command menu: the default (fallback) list is English; a Russian-scoped list
// localises the labels for ru clients. A language scope replaces the whole list, so
// it repeats /start with its own Russian label.
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
Commands: []models.BotCommand{
{Command: "start", Description: "Play Scrabble"},
{Command: "support", Description: "Contact the administration"},
},
Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}},
}); err != nil {
t.log.Warn("set commands failed", zap.Error(err))
}
if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{
LanguageCode: "ru",
Commands: []models.BotCommand{
{Command: "start", Description: "Играть в «Эрудита»"},
{Command: "support", Description: "Связаться с администрацией"},
},
}); err != nil {
t.log.Warn("set ru commands failed", zap.Error(err))
}
if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{
MenuButton: models.MenuButtonWebApp{
Type: models.MenuButtonTypeWebApp,
@@ -1,63 +0,0 @@
package bot
import (
"context"
"strings"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
)
// The /support command replies with a fixed support-desk info message — the operators'
// working hours and what to include (a description and, when possible, screenshots). It
// is a dedicated command handler (registered like /start), so it intercepts "/support"
// before the support relay: the command line itself is not forwarded into an operator
// topic, while the user's following description still is.
// handleSupport replies to /support with the localized support-desk info message. Like
// handleStart it answers only in a private chat — in the moderated group the bot never
// chats — and it sends a plain text message with no launch button.
func (t *Bot) handleSupport(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil {
return
}
if update.Message.Chat.Type != models.ChatTypePrivate {
return
}
// The sender's Telegram language rides on the message itself (Message.from.language_code);
// fall back to English when it is absent, matching startText.
lang := ""
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: supportText(lang),
}); err != nil {
t.log.Warn("reply to support failed", zap.Error(err))
}
}
// supportText returns the localized /support reply. Russian is used when lang (the IETF
// language tag the Telegram client reports on the message's sender) starts with "ru",
// English otherwise and when it is absent — mirroring startText's language choice.
func supportText(lang string) string {
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return ruSupport
}
return enSupport
}
// ruSupport and enSupport are the Russian and English /support replies; the English one
// is the fallback for any non-Russian or missing sender language.
const (
ruSupport = "Поддержка «Эрудита» на связи с 08:00 до 18:00 (по времени UTC). " +
"Если Вы столкнулись с проблемой, подробно опишите её и добавьте, по возможности, скриншоты. " +
"Также будем рады выслушать Ваши пожелания и предложения по функционалу игры. " +
"Ответим в самое ближайшее время."
enSupport = "“Erudite” support is available from 08:00 to 18:00 (UTC). " +
"If you have run into a problem, please describe it in detail and attach screenshots if you can. " +
"We would also be glad to hear your wishes and suggestions about the game's features. " +
"We will reply as soon as possible."
)
@@ -1,51 +0,0 @@
package bot
import (
"context"
"strings"
"testing"
"github.com/go-telegram/bot/models"
)
func TestHandleSupportRepliesPrivateOnly(t *testing.T) {
t.Run("english by default", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
}})
if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID)
}
// No reported language -> English support reply.
if !strings.Contains(api.text, "support is available") {
t.Errorf("text = %q, want the English support reply", api.text)
}
// A plain info message carries no launch button.
if api.replyMarkup != "" {
t.Errorf("reply_markup = %q, want none", api.replyMarkup)
}
})
t.Run("russian for a ru sender", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/support",
From: &models.User{ID: 7, LanguageCode: "ru"},
}})
if !strings.Contains(api.text, "Поддержка «Эрудита»") {
t.Errorf("text = %q, want the Russian support reply", api.text)
}
})
t.Run("group ignored", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleSupport(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: -100, Type: models.ChatTypeSupergroup}, Text: "/support",
}})
if api.chatID != "" {
t.Errorf("group /support got a reply (chat=%q); the bot never chats in the group", api.chatID)
}
})
}
+4 -5
View File
@@ -23,11 +23,10 @@ ENV APP_VERSION=${VERSION}
WORKDIR /app
COPY --from=build /src/renderer/node_modules ./node_modules
COPY --from=build /src/renderer/dist ./dist
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs renderer/src/legal.mjs ./src/
# The public legal pages: the owner-edited markdown, read once at boot. GET /offer/ also splices in
# the live price list fetched from the backend at request time; GET /privacy/ and GET /eula/ are
# static (no dynamic data).
COPY ui/legal/offer_ru.md ui/legal/offer_en.md ui/legal/privacy_ru.md ui/legal/privacy_en.md ui/legal/eula_ru.md ui/legal/eula_en.md ./legal/
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
# price list is fetched from the backend at request time and spliced in.
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
USER node
EXPOSE 8090
CMD ["node", "src/server.mjs"]
+9 -21
View File
@@ -1,11 +1,10 @@
# renderer — the render sidecar (finished-game image + public legal pages)
# renderer — the render sidecar (finished-game image + public offer page)
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 **legal pages** (the offer, the privacy policy and the EULA), each
bilingual (RU + EN) with a language + theme switcher.
signed off) and the public **offer page**.
## Interface
@@ -17,16 +16,9 @@ bilingual (RU + EN) with a language + theme switcher.
- `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 of both language sources (`offer_ru.md` /
`offer_en.md`), then rendered as one bilingual page by the shared `ui/src/lib/offer.ts`
`renderLegalHtml` (the English view transliterates the Russian product names client-side).
`GET /offer` → 301 `/offer/`. Caddy routes `/offer/` here; a backend outage yields a 502, never a
stale price list.
- `GET /privacy/` — the privacy policy, and `GET /eula/` the end-user licence agreement, both as
`text/html`: the owner-edited `ui/legal/{privacy,eula}_{ru,en}.md` (baked in, read at boot),
rendered once at boot as one bilingual page by the same shared `renderLegalHtml` and served from
cache. **Static** — no backend fetch. `GET /privacy` / `GET /eula` → 301 to the trailing-slash
form. Caddy routes these here too.
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
The service renders and nothing else: for the PNG, authentication, the participant check and the
@@ -40,14 +32,10 @@ page.
```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/legal render)
# local run on :8090 (RENDERER_PORT overrides). The server reads all three legal sources at boot, so
# point each at its ui/legal source (the baked paths do not exist in a local checkout); /offer/ also
# needs a reachable backend for the price fetch:
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_OFFER_EN_MD=../ui/legal/offer_en.md \
RENDERER_PRIVACY_MD=../ui/legal/privacy_ru.md RENDERER_PRIVACY_EN_MD=../ui/legal/privacy_en.md \
RENDERER_EULA_MD=../ui/legal/eula_ru.md RENDERER_EULA_EN_MD=../ui/legal/eula_en.md \
RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
pnpm test # bundles, then node --test (PNG smoke + offer splice)
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
# reachable backend (else the boot read / the price fetch fail):
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
```
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
+3 -4
View File
@@ -2,9 +2,8 @@
// 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 / renderLegalHtml: the public legal pages — GET /offer/ (with the live
// catalog price list spliced in), GET /privacy/ and GET /eula/ (static) — server-side so one
// renderer serves them all.
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
// used to invoke, now server-side so the live catalog price list can be spliced in.
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet';
export { renderOfferHtml, renderLegalHtml } from '../../ui/src/lib/offer';
export { renderOfferHtml } from '../../ui/src/lib/offer';
-24
View File
@@ -1,24 +0,0 @@
// The public legal pages (GET /privacy/, GET /eula/): the owner-edited RU + EN markdown under
// ui/legal/, rendered by the SAME shared renderLegalHtml the offer uses (bundled from ui/src/lib/offer)
// into one bilingual page with a language + theme switcher. Unlike the offer, these carry no dynamic
// data — no backend fetch, no marker splice. Kept out of server.mjs so the per-page title/canonical
// presets are unit-testable without HTTP (test/legal.test.mjs).
import { renderLegalHtml } from '../dist/gameimage.mjs';
// renderPrivacy renders the privacy-policy markdown (RU + EN) as the standalone /privacy/ page.
export function renderPrivacy(ruMd, enMd) {
return renderLegalHtml({
ru: { md: ruMd, title: 'Политика конфиденциальности — Эрудит' },
en: { md: enMd, title: 'Privacy Policy — Erudit' },
canonical: 'https://erudit-game.ru/privacy/',
});
}
// renderEula renders the end-user licence agreement markdown (RU + EN) as the standalone /eula/ page.
export function renderEula(ruMd, enMd) {
return renderLegalHtml({
ru: { md: ruMd, title: 'Пользовательское соглашение — Эрудит' },
en: { md: enMd, title: 'Terms of Use — Erudit' },
canonical: 'https://erudit-game.ru/eula/',
});
}
+9 -13
View File
@@ -1,21 +1,17 @@
// The public-offer page renderer: splices the live catalog price list into BOTH language sources of
// the owner-edited offer markdown at the pricing marker, and renders the bilingual /offer/ page with
// the SAME renderOfferHtml the browser build uses (bundled from ui/src/lib/offer). The English view
// transliterates the Russian product names client-side (renderLegalHtml). Kept out of server.mjs so
// the substitution is unit-testable without HTTP.
// 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,en}.md carry at §4.4 where the price list belongs. It
// 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 RU + EN offer markdown with the pricing marker
// 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 bilingual 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(ruMarkdown, enMarkdown, pricingTables) {
const splice = (md) => md.replace(pricingMarker, () => pricingTables);
return renderOfferHtml(splice(ruMarkdown), splice(enMarkdown));
// 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));
}
+13 -44
View File
@@ -2,44 +2,29 @@
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
//
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
// price list spliced in; caddy routes /offer/ here)
// GET /privacy/ → text/html (the privacy policy: ui/legal/privacy_ru.md, static)
// GET /eula/ → text/html (the EULA: ui/legal/eula_ru.md, static)
// GET /offer, /privacy, /eula → 301 to the trailing-slash form
// GET /healthz → 200
// 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). The legal pages (/offer/, /privacy/, /eula/) are reachable from the
// edge but read-only and unprivileged: /offer/ splices the price list fetched from the backend's
// internal endpoint into the committed offer markdown; /privacy/ and /eula/ are static committed
// markdown. No user input reaches any of them.
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
// markdown; no user input reaches it.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { renderRequest } from './render.mjs';
import { renderOffer } from './offer.mjs';
import { renderPrivacy, renderEula } from './legal.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;
// Each legal page is bilingual (RU + EN); both markdown sources are baked into the image
// (renderer/Dockerfile) and read once at boot. The offer's price list is the only dynamic part
// (fetched per request from the backend's internal endpoint and spliced in); privacy + eula carry no
// dynamic data. The RENDERER_*_MD / RENDERER_*_EN_MD env vars override the baked paths for a local run.
const rd = (p) => readFileSync(p, 'utf8');
const OFFER_MD = rd(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url));
const OFFER_EN_MD = rd(process.env.RENDERER_OFFER_EN_MD || new URL('../legal/offer_en.md', import.meta.url));
const PRIVACY_MD = rd(process.env.RENDERER_PRIVACY_MD || new URL('../legal/privacy_ru.md', import.meta.url));
const PRIVACY_EN_MD = rd(process.env.RENDERER_PRIVACY_EN_MD || new URL('../legal/privacy_en.md', import.meta.url));
const EULA_MD = rd(process.env.RENDERER_EULA_MD || new URL('../legal/eula_ru.md', import.meta.url));
const EULA_EN_MD = rd(process.env.RENDERER_EULA_EN_MD || new URL('../legal/eula_en.md', import.meta.url));
// Render the static legal pages once at boot and serve the cached HTML: re-parsing the large EULA on
// every request is slow enough under deploy-time host contention (the renderer is CPU/memory-capped)
// to flake the contour probe. The offer stays per-request because it splices in the live price list.
const PRIVACY_HTML = renderPrivacy(PRIVACY_MD, PRIVACY_EN_MD);
const EULA_HTML = renderEula(EULA_MD, EULA_EN_MD);
// 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';
@@ -83,28 +68,12 @@ const server = createServer(async (req, res) => {
return;
}
if (req.method === 'GET' && path === '/offer/') {
const html = renderOffer(OFFER_MD, OFFER_EN_MD, await fetchOfferPricing());
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 === 'GET' && (path === '/privacy' || path === '/eula')) {
res.writeHead(301, { location: path + '/' }).end();
return;
}
if (req.method === 'GET' && path === '/privacy/') {
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(PRIVACY_HTML);
return;
}
if (req.method === 'GET' && path === '/eula/') {
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(EULA_HTML);
return;
}
if (req.method === 'POST' && path === '/render') {
const png = await renderRequest(JSON.parse(await readBody(req)));
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
-27
View File
@@ -1,27 +0,0 @@
// Unit test for the static legal pages: renderPrivacy / renderEula wrap the owner-edited RU + EN
// markdown in the shared bilingual legal-page chrome (bundled renderLegalHtml) with the right title +
// canonical URL and no dynamic data. The prose rendering itself is unit-tested in ui/ (offer.test.ts);
// here we assert the per-page presets and that both languages reach the document.
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderPrivacy, renderEula } from '../src/legal.mjs';
test('renderPrivacy wraps the RU + EN markdown in the privacy-page chrome', () => {
const html = renderPrivacy(
'# Политика конфиденциальности\n\n**1.1.** ИНН 290210610742.',
'# Privacy Policy\n\n**1.1.** TIN 290210610742.',
);
assert.ok(html.includes('<!doctype html>'), 'a standalone document');
assert.ok(html.includes('<title>Политика конфиденциальности — Эрудит</title>'), 'the privacy title');
assert.ok(html.includes('data-title-en="Privacy Policy — Erudit"'), 'the English title for the toggle');
assert.ok(html.includes('href="https://erudit-game.ru/privacy/"'), 'the privacy canonical URL');
assert.ok(html.includes('290210610742'), 'the prose reached the document');
assert.ok(html.includes('<main data-lang="en" hidden>'), 'the English body is present, hidden by default');
});
test('renderEula wraps the RU + EN markdown in the EULA-page chrome', () => {
const html = renderEula('# Лицензионное соглашение\n\nтекст', '# Terms of Use\n\ntext');
assert.ok(html.includes('<title>Пользовательское соглашение — Эрудит</title>'), 'the EULA title');
assert.ok(html.includes('data-title-en="Terms of Use — Erudit"'), 'the English title for the toggle');
assert.ok(html.includes('href="https://erudit-game.ru/eula/"'), 'the EULA canonical URL');
});
+11 -13
View File
@@ -1,7 +1,7 @@
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list markdown
// into BOTH language sources of the offer at the pricing marker and renders them 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 reaches both languages and the tables render.
// 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';
@@ -11,21 +11,19 @@ const tables =
'| --- | --- | --- | --- |\n' +
'| 50 «Фишек» | 200.00 | 30 | 100 |';
test('splices the price list into both language sources and renders the tables', () => {
const ru = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
const en = `# Public offer\n\n**4.4.** Cost of the Goods:\n\n${pricingMarker}\n`;
const html = renderOffer(ru, en, tables);
// The marker is gone from both languages and the projected table reached the document.
assert.ok(!html.includes(pricingMarker), 'the pricing marker must be substituted in both languages');
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');
assert.ok(html.includes('<h1>Публичная оферта</h1>'), 'the Russian heading rendered');
assert.ok(html.includes('<h1>Public offer</h1>'), 'the English heading rendered');
// 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(`ru ${pricingMarker}`, `en ${pricingMarker}`, '| $5 pack | 1 |');
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |');
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
});
-5
View File
@@ -31,11 +31,6 @@ enables the "Link Telegram" web sign-in (the Login Widget) — inert until the s
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
in the RuStore MVP), `VITE_RUSTORE_URL` (the update overlay's store target; empty until published) and
`VITE_APP_VERSION` (`git describe --tags` → the `X-Client-Version` gate header) — all wired by
`.gitea/workflows/android-build.yaml`; see `ANDROID_PLAN.md`.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
-101
View File
@@ -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
-81
View File
@@ -1,81 +0,0 @@
apply plugin: 'com.android.application'
android {
namespace = "ru.eruditgame.app"
compileSdk = rootProject.ext.compileSdkVersion
// Release signing is supplied by the android-build CI workflow via env: it decodes the keystore
// secret to a file and points ANDROID_KEYSTORE_FILE at it. A local build or a keyless CI run leaves
// it unset, so the release APK is produced UNSIGNED rather than failing — assembleDebug and
// assembleRelease both build without the keystore. The keystore is never committed (see .gitignore).
def keystoreFile = System.getenv('ANDROID_KEYSTORE_FILE')
def hasKeystore = keystoreFile != null && !keystoreFile.isEmpty() && file(keystoreFile).exists()
defaultConfig {
applicationId "ru.eruditgame.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
// Deterministic from the release tag by the android-build workflow (vMA.MI.PA ->
// MA*1_000_000 + MI*1_000 + PA); the defaults keep a local assembleDebug building.
versionCode = (project.findProperty('versionCode') ?: '1').toInteger()
versionName = (project.findProperty('versionName') ?: '0.0.0').toString()
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
// Populated only when the workflow provided a keystore (see hasKeystore above); left empty
// otherwise so it is never referenced with a null storeFile.
if (hasKeystore) {
storeFile file(keystoreFile)
storePassword System.getenv('ANDROID_KEYSTORE_PASSWORD')
keyAlias System.getenv('ANDROID_KEY_ALIAS')
keyPassword System.getenv('ANDROID_KEY_PASSWORD')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
// Sign only when a keystore was supplied; a keyless release build stays unsigned instead
// of failing.
if (hasKeystore) {
signingConfig signingConfigs.release
}
}
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
-20
View File
@@ -1,20 +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')
implementation project(':capacitor-network')
}
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 913 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

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