Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d1fadb50c | |||
| 09e05eef18 | |||
| 73baf58002 | |||
| eec225c4ee | |||
| ca2c6487cf | |||
| e7cb60c996 | |||
| 49c53794f4 | |||
| 4a0689a4ac | |||
| 0eb72ba955 | |||
| e077258567 | |||
| a035edfb54 | |||
| bcd5a1d02d | |||
| a57fd355ba | |||
| 2ea91a8354 | |||
| de003e862a | |||
| aaf2825260 | |||
| 0f5db0ee91 | |||
| 53b33073ac | |||
| e3c5cff7b7 | |||
| 0c5e40c509 | |||
| edaf2dfd9e | |||
| 3ce460f72a | |||
| 4ba9da6721 | |||
| ad1cc361e9 | |||
| 2c2316fb5e | |||
| bb71e7b1c7 | |||
| 5c1f64c7d1 | |||
| b3cf024e9e | |||
| 6e120bdaa7 | |||
| 40acbcccdd | |||
| b54371845f | |||
| b6c2598710 |
@@ -0,0 +1,242 @@
|
||||
# Agent field notes — scrabble-game
|
||||
|
||||
Non-obvious, hard-won project knowledge that is **not** in the main docs (`CLAUDE.md`, `docs/*`,
|
||||
`deploy/README.md`). Kept in the repo so it travels with a clone to any host. These are working
|
||||
memory, not a spec — **verify any named file / flag / function against the current code before acting
|
||||
on it**, and prefer the authoritative docs where they overlap. Add to this file as new gotchas turn up.
|
||||
|
||||
## Codegen & build
|
||||
|
||||
- **jetgen churns everything.** `backend/cmd/jetgen` regenerates go-jet code for *all* tables and may
|
||||
reorder output. After running it, revert the churn on tables you did not touch and commit only your
|
||||
table's change.
|
||||
- **flatc is version-pinned** (`pkg/Makefile`, `REQUIRED_FLATC = 23.5.26`, hard-checked). A different
|
||||
flatc silently churns the generated wire code and can flip wire defaults. Never regenerate FBS with
|
||||
another flatc version.
|
||||
- **gopls lags codegen.** Right after an FBS/jet regen, gopls shows phantom "undefined" errors. Trust
|
||||
`go build` / `go test`, not the editor squiggles.
|
||||
- **go-jet type mapping:** SQL `numeric` → `float64`, `interval` → `string`. Never store money as
|
||||
`numeric` (float precision loss) — use **bigint minor units + a `Money` type**; store durations as
|
||||
**int seconds**, not `interval`.
|
||||
- **`go mod tidy` chokes on dot-free local module paths** (`scrabble/...`). Hand-edit `go.mod` when a
|
||||
bump is needed. The solver (`../scrabble-solver`) is consumed via `go.work` replace locally, but a
|
||||
prod bump goes through a solver **PR → master + a published tag**, not a local replace.
|
||||
- **Run the whole CI suite locally before pushing** — unit + integration (`//go:build integration`,
|
||||
Postgres) + the UI job + codegen check. Do not lean on CI to catch what a local run would.
|
||||
- **CI runner shares this host's `/tmp` as a different user.** In workflow steps, write artifacts to
|
||||
`${GITHUB_WORKSPACE}`, never a fixed `/tmp/...` path (cross-user permission failures otherwise).
|
||||
- **pnpm corepack pre-flight flakes.** `pnpm exec` / `pnpm check` occasionally abort on a corepack
|
||||
pre-flight. Dodge it by invoking the tool directly: `node_modules/.bin/<tool>`.
|
||||
|
||||
## Native Android build (Capacitor)
|
||||
|
||||
The native Android app (`ANDROID_PLAN.md`) is a Capacitor 8 wrapper of the `ui` SPA, scaffolded under
|
||||
`ui/` (a Node project outside `go.work`). Hard-won bring-up facts — verify against current code:
|
||||
|
||||
- **Capacitor 8 needs JDK 21, not 17.** `@capacitor/android` compiles at `VERSION_21`; a JDK 17 Gradle
|
||||
run dies with `error: invalid source release: 21`. Install sudo-free via the Homebrew **formula**
|
||||
`brew install openjdk@21` (the `temurin@21` **cask** wants sudo for a system `.pkg`, unusable
|
||||
non-interactively) + a user symlink into `~/Library/Java/JavaVirtualMachines/` so
|
||||
`/usr/libexec/java_home -v 21` finds it. JDK 17 stays fine for `sdkmanager`/`avdmanager`.
|
||||
- **Cap 8 SDK pins:** compileSdk/targetSdk **36**, minSdk **24**, Gradle **8.14.3**, AGP **8.13.0**
|
||||
(`ui/android/variables.gradle`). Install `platforms;android-36` — Android Studio's newer
|
||||
`android-36.1` does NOT satisfy compileSdk 36.
|
||||
- **SDK is Android Studio's** at `~/Library/Android/sdk` (no `cmdline-tools` by default → `brew install
|
||||
--cask android-commandlinetools`, then `sdkmanager`/`avdmanager --sdk_root=$HOME/Library/Android/sdk`).
|
||||
Gradle finds it via **`ANDROID_HOME`** (`local.properties` is gitignored, machine-specific).
|
||||
- **Build recipe:** `cd ui && pnpm build && node_modules/.bin/cap sync android`; then `cd ui/android &&
|
||||
ANDROID_HOME=~/Library/Android/sdk JAVA_HOME=$(/usr/libexec/java_home -v 21) ./gradlew assembleDebug`
|
||||
→ `ui/android/app/build/outputs/apk/debug/app-debug.apk`. Prefer the local `ui/node_modules/.bin/cap`
|
||||
(dodges the corepack flake).
|
||||
- **Emulator smoke:** existing AVDs `Pixel_10` / `Pixel_4_Android_10_API_29` / `Pixel_Android_9`;
|
||||
`emulator -avd <name>`, `adb install -r <apk>`, `adb shell monkey -p ru.eruditgame.app -c
|
||||
android.intent.category.LAUNCHER 1`, `adb exec-out screencap -p > x.png`. The boot wait needs `sleep`
|
||||
→ run it as a **background** Bash task (foreground `sleep` is blocked). Browsers/WebView are cached, so a
|
||||
full offline vs_ai turn is drivable via `adb shell input tap` (tap through the first-run coachmark tour —
|
||||
each tap advances one step — then Hint places a suggested word; commit → the robot replies).
|
||||
- **Android 15+ edge-to-edge safe-area (WebView-version-dependent, bit me on API 37).** targetSdk 36 forces
|
||||
edge-to-edge — the WebView draws behind the status bar (top) and the gesture-nav home indicator (bottom).
|
||||
On Android **WebView < 140**, `env(safe-area-inset-*)` wrongly reports **0**, so chrome relying on it draws
|
||||
under the bars and is untappable: the top nav under the clock, and the game's bottom action bar's **centre**
|
||||
button (Hint) under the home-indicator pill (side buttons still work; `navigation_mode`=2 is gesture nav).
|
||||
Fix is CSS-only, in TWO parts: (1) Capacitor 8's **SystemBars** plugin (built into `@capacitor/core`,
|
||||
`insetsHandling:'css'` default — no dep, no config) injects correct `--safe-area-inset-*`; consume them as
|
||||
`--tg-safe-*: var(--safe-area-inset-*, env(safe-area-inset-*, 0px))` (`ui/src/app.css`) — fixes any consumer
|
||||
that ALREADY applies the token (the bottom bars: `Game.svelte`/`Screen.svelte` `--tg-safe-bottom`).
|
||||
(2) But a consumer that never applied the top inset on the native path is NOT fixed by the token alone — the
|
||||
**header's** top inset was Telegram-fullscreen-scoped only, so the native header sat under the status bar on
|
||||
EVERY WebView; it needed its own `.bar { padding-top: calc(var(--safe-area-inset-top, 0px) + 5px) }`
|
||||
(`Header.svelte`, native-only via the plugin var; tg-fullscreen still overrides via specificity). **Measure
|
||||
per element, don't eyeball** — a centred title at y=11 under a 54px bar reads as "fine" in a screenshot but
|
||||
is overlapping; an emulator WebView auto-updated to ≥140 (Chrome 149) also hides the `env()`=0 half (so the
|
||||
BOTTOM looks fine there while the user's < 140 device overlaps). **Inspect a live debug WebView** over CDP:
|
||||
`adb forward tcp:9222 localabstract:$(adb shell cat /proc/net/unix | grep -o 'webview_devtools_remote_[0-9]*'
|
||||
| head -1)`, then Playwright `chromium.connectOverCDP('http://localhost:9222')` → `page.evaluate` (read each
|
||||
element's `getBoundingClientRect().top`, and `--safe-area-inset-*` vs `env(...)`).
|
||||
- **`sharp` is whitelisted** in `ui/pnpm-workspace.yaml` (`allowBuilds: sharp: true`) — `@capacitor/assets`
|
||||
uses it for `pnpm android:assets` (launcher icon/splash); else pnpm 11 raises `ERR_PNPM_IGNORED_BUILDS`.
|
||||
- **Bundled offline dicts come from the `scrabble-dictionary` release** (`scrabble-dawg-<DICT_VERSION>.tar.gz`,
|
||||
keyed on the `DICT_VERSION` Gitea var — the same source the backend image + CI `curl`), NOT
|
||||
`scrabble-solver/dawg` (those are the solver's pinned test fixtures).
|
||||
|
||||
## Wire / schema evolution (client ↔ gateway ↔ backend)
|
||||
|
||||
- **FBS is additive-only.** Add **trailing** fields ("added trailing — backward-compatible"). Never
|
||||
delete or reorder a mid-table field — **deprecate** it (`(deprecated)`); deleting shifts field IDs
|
||||
and breaks older readers.
|
||||
- **Retiring a domain field must also retire the WIRE field it fed** — by deprecation, not deletion,
|
||||
and do not just zero it: a dead `0` the client dutifully syncs will clobber the real value.
|
||||
- **The gateway transcodes FBS ↔ backend JSON DTOs in lockstep.** A wire change usually means editing
|
||||
both the FBS schema and the backend DTO.
|
||||
- **Seat display name is built in two places** — `game.Service` live events **and** the server REST
|
||||
DTOs. Change both or they drift.
|
||||
- **A per-viewer flag is computed only in the per-viewer REST DTO**; the client seeds it from REST,
|
||||
then bumps it from the live event. Don't expect it on the shared broadcast.
|
||||
|
||||
## UI / Svelte 5
|
||||
|
||||
- **Never name a `$state` variable `state`.** `svelte-check` then misreads `$state` as a store
|
||||
subscription. Rename (e.g. `view`).
|
||||
- **Svelte trims literal edge whitespace** in markup. To keep a separator space, emit an expression:
|
||||
`{' : '}`.
|
||||
- **No global `.btn` / `.ghost` button classes.** Style buttons per-component with scoped CSS + design
|
||||
tokens (mirror `NewGame`'s `.invite`).
|
||||
- **A `$state` proxy fails structured-clone** when persisted to IndexedDB. Snapshot at the call site
|
||||
with `$state.snapshot(...)` before storing.
|
||||
|
||||
## Platform / WebView quirks (Telegram, VK, iOS, native, old Android)
|
||||
|
||||
- **Telegram `showPopup` eats the user-activation.** Share / clipboard called from inside a
|
||||
`showPopup` callback fail (no gesture). Use your own `Modal` for gesture-gated Web APIs.
|
||||
- **iOS Telegram `<a download blob:>` navigates away** and strands the SPA. Deliver files by **Web
|
||||
Share on mobile**, and only use a `<a download>` Blob path on **desktop**.
|
||||
- **Android TG/VK WebViews** lack `navigator.share` and ignore `<a download>`. Deliver a
|
||||
client-generated file by **copying it to the clipboard**.
|
||||
- **VK Android WebView ignores `target=_blank`.** Open external links through `lib/links.ts`
|
||||
(routes to `vk.com/away.php`). Verify on-device.
|
||||
- **Per-platform file-delivery last hop differs** (TG / VK / plain browser) — there is a delivery
|
||||
matrix; do not re-litigate it without new on-device facts.
|
||||
- **Old Android System WebView floor ≈ Chrome 67.** The bundle targets **es2019** (esbuild lowers
|
||||
syntax), a conditional `core-js` polyfill loads only on old engines, and `index.html` has a boot
|
||||
gate (BigInt / Proxy are the hard block → the unsupported-engine screen). There's also a `vmin`
|
||||
glyph fallback for old rendering.
|
||||
- **iOS WKWebView overscroll and Telegram swipe-to-close are not reproducible in Playwright.** Verify
|
||||
those live on the deployed contour, not in the e2e.
|
||||
- **Telegram Desktop Mini App shows a persistent bottom-right loader** — that's the long-lived
|
||||
Subscribe stream, cosmetic, deliberately left as-is.
|
||||
|
||||
## Testing
|
||||
|
||||
- **UI test layers:** vitest (node env, pure logic, **no jsdom**) + Playwright **mock** e2e. The mock
|
||||
e2e **bypasses the codec**, so wire/codec bugs need **codec unit tests**, not e2e coverage.
|
||||
- **Mock overlay blocks e2e.** The cold-load overlay must be instant under the mock build or it
|
||||
intercepts Playwright taps.
|
||||
- **Mock tile pools lack a blank `'?'`.** The seeded game `G1` hard-codes one; flip `G1`'s variant to
|
||||
eyeball per-variant tiles.
|
||||
- **Durable Playwright MCP servers** (chromium + webkit) exist for UI inspection (there's a
|
||||
plugin-config gotcha in wiring them).
|
||||
- **The `playwright test` runner can't *fetch* browsers in this sandbox** — `playwright install` dies
|
||||
with `EBADF` against the Playwright CDN (blocked network), even with the sandbox off. Run the e2e in
|
||||
**CI** (the `ui` job installs chromium+webkit), or drive a state live through the **Playwright MCP**
|
||||
browser against a local `vite --mode mock` server for visual verification. **But if chromium/webkit are
|
||||
already cached** in `~/Library/Caches/ms-playwright/`, `playwright test` runs locally fine (only the CDN
|
||||
fetch is blocked) — the native offline-first e2e was run green locally this way (chromium + webkit),
|
||||
its dawgs from the sibling `../../scrabble-solver/dawg` via the webServer's `bundle-dicts.mjs` fallback.
|
||||
- **A native (Capacitor) e2e must inject `window.androidBridge`, NOT `window.Capacitor.getPlatform`.**
|
||||
`@capacitor/core` (pulled in during boot by `initNativeShell`'s dynamic `@capacitor/app` import)
|
||||
**replaces** any pre-set `window.Capacitor` with its own shim and derives the platform from
|
||||
`window.androidBridge` (android) / `window.webkit.messageHandlers` (ios) — so a bare injected
|
||||
`Capacitor.getPlatform: () => 'android'` is clobbered to `web` and the boot falls to `/login`. Inject
|
||||
`window.androidBridge = { postMessage(){} }` in an `addInitScript` (see `e2e/native.spec.ts` `simulateNative`);
|
||||
`initNativeShell` is written to tolerate the stub bridge (`try/catch` round the `@capacitor/app` addListener).
|
||||
- **`docker run -p ...` boot tests fail from the shell** (published ports unreachable in this env).
|
||||
Use **testcontainers** for container-backed tests.
|
||||
- **Distroless images run as UID 65532 (nonroot).** Bind-mounted TLS keys must be **0644** (not 0600)
|
||||
or the service crash-loops on start.
|
||||
|
||||
## Deploy / test contour (operational)
|
||||
|
||||
- **The TEST contour runs on THIS dev host.** Inspect it via `docker` / Prometheus. A host-side
|
||||
`curl` to caddy **hangs** (NAT hairpin) — don't debug the edge that way.
|
||||
- **The contour's client IP is the home-router SNAT address**, not the real external IP. Correct in
|
||||
prod, not a bug — which is why the IP ban / blocklist are **prod-only**.
|
||||
- **The contour is one shared env, last-deploy-wins.** Keep a multi-PR batch a **linear stack** (one
|
||||
PR, one deploy) so deploys don't clobber each other.
|
||||
- **A schema/wire PR breaks the contour** until a `DROP SCHEMA` + backend restart. Note such a
|
||||
prerelease step in `PRERELEASE.md`.
|
||||
- **A contour DB wipe resets the account but not the client's stored locale**; the reconciler then
|
||||
syncs the stale locale, masking a fresh TG `language_code` seed. Clear client prefs too when testing
|
||||
locale.
|
||||
- **`DNS=` in `TEST_AWG_CONF` pins the VPN netns to 1.1.1.1** → internal names go NXDOMAIN → the
|
||||
bot-link silently dies. Diagnose from inside the netns.
|
||||
- **Swap one contour service to a local image** without deploy secrets via a busybox-in-the-netns
|
||||
socket-inspect trick (single-service recreate).
|
||||
- **A rolling deploy did NOT recreate caddy on a config-only change.** Force `--force-recreate` for
|
||||
caddy; don't trust "deploy green" for an edge-config change.
|
||||
- **A new gateway edge route MUST be added to the Caddyfile `@gateway` matcher** or it falls through
|
||||
to the landing catch-all. Add a CI probe for the route.
|
||||
- **Prod caddy logs warnings only, no access log.** Trace a request via the backend "http request"
|
||||
telemetry log or Tempo, not caddy.
|
||||
- **Confirm a release is live without SSH** by grepping the served SPA: `__APP_VERSION__` inside
|
||||
`/assets/main-*.js`.
|
||||
- **"App hangs on load" was a dead HTTP/3 advert:** caddy sent `Alt-Svc: h3` with no UDP/443 open;
|
||||
clients cache it ~30 days. Fix with `Alt-Svc: clear`.
|
||||
- **Config-poison deploy loop:** a crash-looping config-mounted service + a missing bind source
|
||||
produces a root-owned directory that then fails deploys. Break it by removing the root-owned dir.
|
||||
- **Maintenance-window contract** (planned-deploy 503): a marker header, `/_gm` exempt, the flag spans
|
||||
the whole roll, the SPA overlay reloads on recovery. Don't break these invariants.
|
||||
- **A new dictionary goes live via a `/_gm/dictionary` upload, NOT a redeploy.** In-flight games keep
|
||||
their pinned version. The **owner** does the upload.
|
||||
- **Renderer deploy job flakes** on the skia-canvas GitHub binary download when its lockfile changes —
|
||||
re-run, it's not your code.
|
||||
|
||||
## Repo workflow
|
||||
|
||||
- **PR-based, zero issues.** Work is tracked via PRs + `PRERELEASE.md`. "заведи задачу" means *do it*
|
||||
/ add a plan line — not file a tracker issue.
|
||||
- **`tea` CLI for all Gitea ops** (PRs, secrets, variables, dispatch). `gh` does **not** work here. The
|
||||
agent **cannot self-approve** a PR but **can merge** it after the owner approves. Watch the
|
||||
stale-mergeable trap (re-check mergeability right before merging).
|
||||
- **Watch every push/merge/deploy to green** with `python3 ~/.claude/bin/gitea-ci-watch.py`, launched
|
||||
**bare** under a background task. It polls run-level conclusions; its `ALL GREEN` already covers the
|
||||
gated deploy job. Pass `--no-runs 600` when the runner is busy. A **merge** is the most-forgotten
|
||||
case — watch the post-merge runs too.
|
||||
- **After a merge, switch to the merged-into branch** (`development`/`master`), pull, and prune the
|
||||
local feature branch.
|
||||
- **The contour deploy probe checks the backend `/readyz`**; a PR deploy builds the PR's own code; a
|
||||
wedged contour can be recovered by recreating the host container set.
|
||||
|
||||
## Domain semantics (not obvious from the code)
|
||||
|
||||
- **Account deletion must NOT delete any user messages** — including feedback / support. Interview the
|
||||
owner on every deletion point before wiring it.
|
||||
- **`account.time_zone` is `NOT NULL DEFAULT 'UTC'`, seeded from a detected ±HH:MM offset.** An email
|
||||
account row is created at the **code-request** step, not at confirmation.
|
||||
- **The robot has two distinct time windows:** a **sleep window** (~00:00–07:00, gates its moves and
|
||||
nudges) vs the **player away window** (turn-timeout only). "окно отсутствия" in dialogue = the
|
||||
**sleep** window.
|
||||
|
||||
## Production topology
|
||||
|
||||
- **Two prod hosts.** `main` — full stack + ACME/edge (Selectel); `tg` — Telegram bot only (vdsina).
|
||||
SSH aliases `scrabble-main-ops` / `scrabble-tg-ops`. Deploy is **manual dispatch only**, rolling +
|
||||
health-gated + auto-rollback. Hosts are provisioned by `deploy/ansible/` (inventory + vars there —
|
||||
the source of truth for IPs/roles).
|
||||
- **The agent has targeted root SSH** to the hosts (and may run the prod Ansible). Surface every
|
||||
owner-side step **before** a deploy, not after.
|
||||
|
||||
## Feature-area pointers (state lives in the linked docs/plans, not here)
|
||||
|
||||
- **Monetization** — «Фишка» currency, per-platform wallets, ads. Agreements in
|
||||
`docs/PAYMENTS_DECISIONS_ru.md`; a phased plan (E0–E9). go-jet money rule above applies.
|
||||
- **PITR** — pgBackRest → Selectel S3 (encrypted, 30-day), currently **gated off**; runbook in
|
||||
`deploy/README.md`. Gotcha: run pgBackRest via docker-exec **as the `postgres` user** with
|
||||
`--pg1-user=scrabble`.
|
||||
- **Offline mode** — the robot brain, move generator/validator/scorer and DAWG reader are **JS ports**
|
||||
of the Go engine, bundled client-side and **parity-pinned** by golden tests. Multi-phase; native
|
||||
offline-first bundles the dictionaries (see `ANDROID_PLAN.md`).
|
||||
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
|
||||
App, server-side confidential code exchange.
|
||||
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
||||
- **Native Android** — see `ANDROID_PLAN.md` (Capacitor bundle model, client-version gate,
|
||||
offline-first, RuStore).
|
||||
@@ -0,0 +1,172 @@
|
||||
# Manual signed-APK build for the standalone Android app (RuStore). Runs ONLY from master, ONLY on
|
||||
# workflow_dispatch with confirm=build — the same deliberate-manual shape as prod-deploy.yaml, never on
|
||||
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and
|
||||
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
|
||||
#
|
||||
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
|
||||
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
|
||||
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook) and
|
||||
# ANDROID_PLAN.md §E. The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the
|
||||
# runner host needs nothing pre-installed.
|
||||
name: android-build
|
||||
run-name: "android build ${{ github.sha }}"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: 'Type "build" to confirm an APK build from master.'
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NO_COLOR: "1"
|
||||
# The dictionary release, one source of truth (same Gitea variable the backend image + CI use). It
|
||||
# both fetches the DAWGs and labels the bundled files (VITE_DICT_VERSION must equal __DICT_VERSION__).
|
||||
DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
VITE_DICT_VERSION: ${{ vars.DICT_VERSION }}
|
||||
# Hide in-app purchases in the RuStore MVP (RuStore, not Google Play — VITE_GP_BUILD stays unset).
|
||||
VITE_PAYMENTS_DISABLED: "1"
|
||||
# The update overlay's store target; empty until publication (the button no-ops, and the version gate
|
||||
# is dormant in the MVP so it never fires). Set the ANDROID_RUSTORE_URL variable when the app is live.
|
||||
VITE_RUSTORE_URL: ${{ vars.ANDROID_RUSTORE_URL }}
|
||||
# Host-executor runner: the Android SDK is pre-installed on the host. Override ANDROID_SDK_DIR if it
|
||||
# lives elsewhere (the default matches deploy/README.md's install path).
|
||||
ANDROID_HOME: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||
ANDROID_SDK_ROOT: ${{ vars.ANDROID_SDK_DIR || '/opt/android-sdk' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ github.ref == 'refs/heads/master' && inputs.confirm == 'build' }}
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Host-executor runner: the Android SDK is pre-installed on the host (JDK 21 comes from setup-java
|
||||
# below). Fail fast + legibly if the runner user cannot read/execute it or a needed package is
|
||||
# missing — this doubles as the runner-access check (deploy/README.md).
|
||||
- name: Verify the host Android SDK
|
||||
run: |
|
||||
sm="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
|
||||
if [ ! -x "$sm" ]; then
|
||||
echo "::error::sdkmanager not found/executable at $sm — set the ANDROID_SDK_DIR variable if the SDK lives elsewhere, or grant the runner user read+exec: sudo chmod -R a+rX \"$ANDROID_HOME\""; exit 1
|
||||
fi
|
||||
for pkg in "platforms/android-36" "build-tools"; do
|
||||
if [ ! -d "$ANDROID_HOME/$pkg" ]; then
|
||||
echo "::error::missing $ANDROID_HOME/$pkg — run: \"$sm\" 'platforms;android-36' 'build-tools;36.0.0'"; exit 1
|
||||
fi
|
||||
done
|
||||
echo "Android SDK OK at $ANDROID_HOME"; "$sm" --version
|
||||
|
||||
- name: Compute version + native build env
|
||||
id: prep
|
||||
env:
|
||||
PUBLIC_BASE_URL: ${{ vars.PROD_PUBLIC_BASE_URL }}
|
||||
run: |
|
||||
# A store release must sit on an exact vMAJOR.MINOR.PATCH tag (G tags before dispatch) so the
|
||||
# versionCode is deterministic and strictly increasing across uploads. Refuse anything else
|
||||
# rather than derive a versionCode from a "-N-gSHA" describe.
|
||||
desc="$(git describe --tags --exact-match 2>/dev/null || true)"
|
||||
case "$desc" in
|
||||
v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "::error::HEAD is not on a clean vX.Y.Z tag (git describe --exact-match = '${desc:-none}'); tag the release first"; exit 1 ;;
|
||||
esac
|
||||
v="${desc#v}"
|
||||
IFS=. read -r MA MI PA <<< "$v"
|
||||
# 10# forces base-10 so a zero-padded part is never read as octal.
|
||||
code=$(( 10#$MA * 1000000 + 10#$MI * 1000 + 10#$PA ))
|
||||
# The native SPA talks to the production origin (reuse the prod public base URL); strip any
|
||||
# trailing slash so the Connect endpoint never doubles it.
|
||||
gateway="${PUBLIC_BASE_URL%/}"
|
||||
{
|
||||
echo "tag=$desc"
|
||||
echo "name=$v"
|
||||
echo "code=$code"
|
||||
echo "gateway=$gateway"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "release $desc -> versionName $v versionCode $code, gateway $gateway"
|
||||
|
||||
# Same release + fetch as the Go/UI jobs in ci.yaml — the bundled dicts come from this tarball,
|
||||
# NOT the scrabble-solver sibling (ui is a Node project outside go.work).
|
||||
- name: Fetch dictionary DAWGs
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/dawg"
|
||||
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
|
||||
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
|
||||
ls -la "${GITHUB_WORKSPACE}/dawg"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm@11.0.9
|
||||
|
||||
- name: Install deps
|
||||
working-directory: ui
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build the SPA (native flavour)
|
||||
working-directory: ui
|
||||
env:
|
||||
VITE_GATEWAY_URL: ${{ steps.prep.outputs.gateway }}
|
||||
VITE_APP_VERSION: ${{ steps.prep.outputs.tag }}
|
||||
run: pnpm run build
|
||||
|
||||
# Copy the release DAWGs into dist/dict/<variant>@<version>.dawg for the offline-first bundled tier
|
||||
# (after the build, before cap sync copies dist/ into the native assets).
|
||||
- name: Bundle the dictionaries
|
||||
working-directory: ui
|
||||
env:
|
||||
DICT_DIR: ${{ github.workspace }}/dawg
|
||||
run: node scripts/bundle-dicts.mjs
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "21"
|
||||
|
||||
# Local cap binary (dodges the corepack pre-flight flake); syncs dist/ (incl. dict/) + native deps.
|
||||
- name: Sync the native project
|
||||
working-directory: ui
|
||||
run: node_modules/.bin/cap sync android
|
||||
|
||||
- name: Decode the release keystore
|
||||
id: keystore
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
run: |
|
||||
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
|
||||
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
|
||||
echo "file=${GITHUB_WORKSPACE}/release.jks" >> "$GITHUB_OUTPUT"
|
||||
echo "keystore decoded -> signed release build"
|
||||
else
|
||||
echo "file=" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
|
||||
fi
|
||||
|
||||
- name: Assemble the release APK
|
||||
working-directory: ui/android
|
||||
env:
|
||||
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
|
||||
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
|
||||
|
||||
- name: Upload the APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: erudit-${{ steps.prep.outputs.name }}-apk
|
||||
path: ui/android/app/build/outputs/apk/release/*.apk
|
||||
if-no-files-found: error
|
||||
@@ -353,6 +353,11 @@ jobs:
|
||||
# for the server-side confidential code exchange — a SEPARATE VK app from the Mini
|
||||
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
# Client-version gate (ARCHITECTURE.md §2): one plain (unprefixed) variable serves every
|
||||
# contour. In the test contour the stamped client version is a commit hash (unparseable ⇒
|
||||
# fail-open), so setting these only enforces on real semver prod builds. Empty ⇒ dormant.
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${{ vars.GATEWAY_MIN_CLIENT_VERSION }}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${{ vars.GATEWAY_RECOMMENDED_CLIENT_VERSION }}
|
||||
# Planted honeytoken bearer: presenting it flags the caller (logs + a ban metric on
|
||||
# test where the IP ban is off; a 24h IP ban on prod). Per-contour secret; empty = trap off.
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||
@@ -510,15 +515,20 @@ jobs:
|
||||
- name: Probe the /offer/ public offer page is served
|
||||
run: |
|
||||
set -u
|
||||
# /offer/ is a static page baked into the landing image (rendered from
|
||||
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
|
||||
# silently falls through to the landing shell (also 200) — so assert offer-specific
|
||||
# content, never just the status.
|
||||
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
|
||||
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
|
||||
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
|
||||
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
|
||||
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
|
||||
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
|
||||
# the marker with nothing, so "pricing_template" must be absent either way.
|
||||
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
|
||||
if echo "$out" | grep -q "290210610742"; then
|
||||
echo "ok: /offer/ serves the public offer page"
|
||||
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
|
||||
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
|
||||
else
|
||||
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
|
||||
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
|
||||
docker logs --tail 50 scrabble-renderer || true
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
docker logs --tail 50 scrabble-landing || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -112,9 +112,20 @@ 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 }}
|
||||
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
|
||||
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
|
||||
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
|
||||
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
|
||||
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
|
||||
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
||||
|
||||
@@ -70,6 +70,10 @@ jobs:
|
||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
||||
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
|
||||
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
|
||||
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
|
||||
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
|
||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
||||
|
||||
@@ -156,3 +156,11 @@ The `ui` module is a Node project (pnpm), **not** in `go.work`; it is the `ui` j
|
||||
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
||||
|
||||
## Agent field notes
|
||||
|
||||
Non-obvious, hard-won knowledge the agent has accumulated that is **not** captured in the docs above,
|
||||
kept in the repo so it travels with a clone. Verify any named file/flag against current code before
|
||||
acting on it.
|
||||
|
||||
@.claude/CLAUDE.md
|
||||
|
||||
@@ -283,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
}
|
||||
logger.Info("payments domain ready")
|
||||
|
||||
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
|
||||
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
|
||||
// failure here only defers the projection to the first read.
|
||||
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
|
||||
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
|
||||
}
|
||||
|
||||
// Wire the payments surface into the domains that consume it: the online-game hint wallet
|
||||
// and the account-merge wallet fold. Done after the reachability check so a broken payments
|
||||
// schema fails boot before anything depends on it.
|
||||
|
||||
@@ -4,6 +4,7 @@ package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
|
||||
t.Error("deactivated product must not appear in the storefront")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
|
||||
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
|
||||
// with its per-rail prices, and archiving it through the service drops it from the next read.
|
||||
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
|
||||
svc := newPaymentsService()
|
||||
ctx := context.Background()
|
||||
title := "OfferTest " + uuid.NewString()
|
||||
id, err := svc.CreateProduct(ctx, payments.ProductInput{
|
||||
Title: title,
|
||||
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
|
||||
Prices: []payments.PriceLine{
|
||||
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
|
||||
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
|
||||
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
|
||||
},
|
||||
}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("create pack: %v", err)
|
||||
}
|
||||
|
||||
md, err := svc.OfferPricing(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("offer pricing: %v", err)
|
||||
}
|
||||
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
|
||||
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
|
||||
}
|
||||
|
||||
// Archiving through the service marks the cache stale; the next read must reproject without it.
|
||||
if err := svc.SetProductActive(ctx, id, false); err != nil {
|
||||
t.Fatalf("archive: %v", err)
|
||||
}
|
||||
md, err = svc.OfferPricing(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("offer pricing after archive: %v", err)
|
||||
}
|
||||
if strings.Contains(md, title) {
|
||||
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -147,13 +150,76 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx)
|
||||
}
|
||||
|
||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
|
||||
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
|
||||
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
|
||||
// products the same as active ones (the admin list shows both).
|
||||
func SortAdminCatalog(products []AdminProduct) {
|
||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
||||
if pa {
|
||||
return -1 // packs (sales) before values (chip exchange)
|
||||
}
|
||||
return 1
|
||||
} else if pa {
|
||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
||||
}
|
||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
||||
})
|
||||
}
|
||||
|
||||
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
|
||||
func adminIsPack(p AdminProduct) bool {
|
||||
for _, a := range p.Atoms {
|
||||
if a.Atom == atomChips {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
|
||||
func adminValueGroup(p AdminProduct) int {
|
||||
hasHints, hasNoAds, hasTournament := false, false, false
|
||||
for _, a := range p.Atoms {
|
||||
switch a.Atom {
|
||||
case "hints":
|
||||
hasHints = true
|
||||
case "noads_days":
|
||||
hasNoAds = true
|
||||
case "tournament":
|
||||
hasTournament = true
|
||||
}
|
||||
}
|
||||
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||
}
|
||||
|
||||
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
|
||||
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
|
||||
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
|
||||
for _, pr := range p.Prices {
|
||||
if pr.Method == method && pr.Currency == cur {
|
||||
return pr.Amount
|
||||
}
|
||||
}
|
||||
return math.MaxInt64
|
||||
}
|
||||
|
||||
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
||||
// product created active must satisfy the sellable shape.
|
||||
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return s.store.createProduct(ctx, in, active, s.clock())
|
||||
id, err := s.store.createProduct(ctx, in, active, s.clock())
|
||||
if err == nil {
|
||||
s.markOfferStale()
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
||||
@@ -166,7 +232,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
|
||||
if err := validateProduct(in, active); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.store.updateProduct(ctx, id, in, s.clock())
|
||||
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
||||
@@ -181,11 +251,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.store.setProductActive(ctx, id, active, s.clock())
|
||||
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
|
||||
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
||||
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
||||
return s.store.deleteProduct(ctx, id)
|
||||
if err := s.store.deleteProduct(ctx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.markOfferStale()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
||||
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||
func TestSortAdminCatalog(t *testing.T) {
|
||||
pack := func(title string, rub int64) AdminProduct {
|
||||
return AdminProduct{
|
||||
Title: title,
|
||||
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
|
||||
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
|
||||
}
|
||||
}
|
||||
value := func(title string, chips int64, atoms ...string) AdminProduct {
|
||||
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
|
||||
for _, a := range atoms {
|
||||
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
|
||||
}
|
||||
return p
|
||||
}
|
||||
products := []AdminProduct{
|
||||
pack("packDear", 30000),
|
||||
value("bundle", 500, "hints", "noads_days"),
|
||||
pack("packCheap", 10000),
|
||||
value("adsOnly", 150, "noads_days"),
|
||||
value("hintsBig", 200, "hints"),
|
||||
value("hintsSmall", 50, "hints"),
|
||||
}
|
||||
SortAdminCatalog(products)
|
||||
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||
for i, p := range products {
|
||||
if p.Title != want[i] {
|
||||
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// titles extracts the product titles for a failure message.
|
||||
func titles(products []AdminProduct) []string {
|
||||
out := make([]string, len(products))
|
||||
for i, p := range products {
|
||||
out[i] = p.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries
|
||||
// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing]
|
||||
// returns before rendering the /offer/ page; the backend only produces the tables, never the marker.
|
||||
const pricingMarker = "<#pricing_template#>"
|
||||
|
||||
// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the
|
||||
// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK
|
||||
// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The
|
||||
// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]),
|
||||
// so a steady-state read issues no query — only the first read after an edit reprojects. The render
|
||||
// sidecar fetches it and splices it into the offer markdown at the pricing marker.
|
||||
func (s *Service) OfferPricing(ctx context.Context) (string, error) {
|
||||
s.offerMu.Lock()
|
||||
defer s.offerMu.Unlock()
|
||||
if s.offerFresh {
|
||||
return s.offerMD, nil
|
||||
}
|
||||
md, err := s.buildOfferPricing(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s.offerMD = md
|
||||
s.offerFresh = true
|
||||
return md, nil
|
||||
}
|
||||
|
||||
// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing]
|
||||
// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it.
|
||||
func (s *Service) markOfferStale() {
|
||||
s.offerMu.Lock()
|
||||
s.offerFresh = false
|
||||
s.offerMu.Unlock()
|
||||
}
|
||||
|
||||
// buildOfferPricing loads the active catalog and projects it into the offer tables.
|
||||
func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
||||
entries, err := s.store.loadCatalog(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return projectOfferPricing(entries), nil
|
||||
}
|
||||
|
||||
// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries
|
||||
// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip
|
||||
// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
|
||||
// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
|
||||
// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is
|
||||
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||
func projectOfferPricing(entries []catalogEntry) string {
|
||||
var packs, values []catalogEntry
|
||||
for _, e := range entries {
|
||||
if isPackEntry(e) {
|
||||
packs = append(packs, e)
|
||||
} else {
|
||||
values = append(values, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
|
||||
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
|
||||
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
|
||||
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
|
||||
})
|
||||
slices.SortStableFunc(values, func(a, b catalogEntry) int {
|
||||
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
if len(packs) > 0 {
|
||||
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||
b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
|
||||
b.WriteString("| --- | ---: | ---: | ---: |\n")
|
||||
for _, e := range packs {
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
|
||||
offerCell(e.title),
|
||||
offerPrice(e, string(SourceDirect), CurrencyRUB),
|
||||
offerPrice(e, string(SourceVK), CurrencyVote),
|
||||
offerPrice(e, string(SourceTelegram), CurrencyStar),
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(values) > 0 {
|
||||
if len(packs) > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
|
||||
b.WriteString("| Наименование | «Фишки» |\n")
|
||||
b.WriteString("| --- | ---: |\n")
|
||||
for _, e := range values {
|
||||
fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
|
||||
func offerValueGroup(e catalogEntry) int {
|
||||
hasHints, hasNoAds, hasTournament := false, false, false
|
||||
for _, a := range e.atoms {
|
||||
switch a.atomType {
|
||||
case "hints":
|
||||
hasHints = true
|
||||
case "noads_days":
|
||||
hasNoAds = true
|
||||
case "tournament":
|
||||
hasTournament = true
|
||||
}
|
||||
}
|
||||
return valueGroup(hasHints, hasNoAds, hasTournament)
|
||||
}
|
||||
|
||||
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
|
||||
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
|
||||
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
|
||||
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
|
||||
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
|
||||
// the catalog shape forbids it).
|
||||
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
|
||||
switch {
|
||||
case hasTournament:
|
||||
return 3
|
||||
case hasHints && hasNoAds:
|
||||
return 2
|
||||
case hasNoAds:
|
||||
return 1
|
||||
case hasHints:
|
||||
return 0
|
||||
default:
|
||||
return 4
|
||||
}
|
||||
}
|
||||
|
||||
// offerSortAmount returns the entry's price in the given method and currency for ordering, or
|
||||
// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading.
|
||||
func offerSortAmount(e catalogEntry, method string, cur Currency) int64 {
|
||||
if amt, ok := offerAmount(e, method, cur); ok {
|
||||
return amt
|
||||
}
|
||||
return math.MaxInt64
|
||||
}
|
||||
|
||||
// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds
|
||||
// chips with money) rather than being a chip-priced value.
|
||||
func isPackEntry(e catalogEntry) bool {
|
||||
for _, a := range e.atoms {
|
||||
if a.atomType == atomChips {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// offerPrice formats the entry's price for the given payment method and currency as a major-unit
|
||||
// string, or an em dash when the entry carries no such price.
|
||||
func offerPrice(e catalogEntry, method string, cur Currency) string {
|
||||
amt, ok := offerAmount(e, method, cur)
|
||||
if !ok {
|
||||
return "—"
|
||||
}
|
||||
m, err := MoneyFromMinor(amt, cur)
|
||||
if err != nil {
|
||||
return "—"
|
||||
}
|
||||
return m.Major()
|
||||
}
|
||||
|
||||
// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and
|
||||
// currency, and whether such a price exists.
|
||||
func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) {
|
||||
for _, pr := range e.prices {
|
||||
if pr.method == method && pr.currency == cur {
|
||||
return pr.amount, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
|
||||
// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows
|
||||
// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not
|
||||
// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the
|
||||
// page), the markdown table pipe and the row newline, and the link brackets (a title must never
|
||||
// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the
|
||||
// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&" does not
|
||||
// double-escape the entities the other rules emit.
|
||||
var offerCellReplacer = strings.NewReplacer(
|
||||
"&", "&",
|
||||
"<", "<",
|
||||
">", ">",
|
||||
`"`, """,
|
||||
"'", "'",
|
||||
"|", `\|`,
|
||||
"[", `\[`,
|
||||
"]", `\]`,
|
||||
"\n", " ",
|
||||
)
|
||||
|
||||
// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of
|
||||
// the public offer (see [offerCellReplacer]).
|
||||
func offerCell(s string) string {
|
||||
return offerCellReplacer.Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
|
||||
// value render into the two tables, pack table first, money formatted through Money.
|
||||
func TestProjectOfferPricing(t *testing.T) {
|
||||
entries := []catalogEntry{
|
||||
{
|
||||
id: uuid.New(),
|
||||
title: "50 «Фишек»",
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
|
||||
prices: []priceRow{
|
||||
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
|
||||
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
|
||||
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: uuid.New(),
|
||||
title: "200 подсказок",
|
||||
atoms: []atomQty{{atomType: "hints", quantity: 200}},
|
||||
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
|
||||
},
|
||||
}
|
||||
md := projectOfferPricing(entries)
|
||||
for _, want := range []string{
|
||||
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
|
||||
"| --- | ---: | ---: | ---: |", // price columns right-aligned
|
||||
"| 50 «Фишек» | 200.00 | 30 | 100 |",
|
||||
"| Наименование | «Фишки» |",
|
||||
"| --- | ---: |",
|
||||
"| 200 подсказок | 50 |",
|
||||
} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("projection missing %q\n---\n%s", want, md)
|
||||
}
|
||||
}
|
||||
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
|
||||
t.Errorf("the pack table must precede the values table:\n%s", md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by
|
||||
// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group.
|
||||
func TestProjectOfferPricingOrdering(t *testing.T) {
|
||||
pack := func(title string, rub int64) catalogEntry {
|
||||
return catalogEntry{
|
||||
id: uuid.New(),
|
||||
title: title,
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: rub}},
|
||||
}
|
||||
}
|
||||
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||
for _, a := range atoms {
|
||||
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||
}
|
||||
return e
|
||||
}
|
||||
// Deliberately out of order on input.
|
||||
entries := []catalogEntry{
|
||||
pack("packDear", 30000),
|
||||
pack("packCheap", 10000),
|
||||
value("bundle", 500, "hints", "noads_days"),
|
||||
value("adsOnly", 150, "noads_days"),
|
||||
value("hintsBig", 200, "hints"),
|
||||
value("hintsSmall", 50, "hints"),
|
||||
}
|
||||
md := projectOfferPricing(entries)
|
||||
order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||
last := -1
|
||||
for _, title := range order {
|
||||
i := strings.Index(md, "| "+title+" |")
|
||||
if i < 0 {
|
||||
t.Fatalf("row %q missing:\n%s", title, md)
|
||||
}
|
||||
if i < last {
|
||||
t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md)
|
||||
}
|
||||
last = i
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
|
||||
// title carrying a pipe is escaped so the table layout survives.
|
||||
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
|
||||
entries := []catalogEntry{{
|
||||
id: uuid.New(),
|
||||
title: "Bonus | pack",
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
|
||||
// A roubles price only — no VK, no Telegram.
|
||||
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
|
||||
}}
|
||||
md := projectOfferPricing(entries)
|
||||
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
|
||||
t.Errorf("want row %q in:\n%s", want, md)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link
|
||||
// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the
|
||||
// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not
|
||||
// survive into the projected markdown.
|
||||
func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) {
|
||||
entries := []catalogEntry{{
|
||||
id: uuid.New(),
|
||||
title: `<script>alert(1)</script> [x](javascript:alert(2)) & "q"`,
|
||||
atoms: []atomQty{{atomType: "hints", quantity: 1}},
|
||||
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}},
|
||||
}}
|
||||
md := projectOfferPricing(entries)
|
||||
for _, bad := range []string{"<script>", "</script>", "[x]", `& "q"`} {
|
||||
if strings.Contains(md, bad) {
|
||||
t.Errorf("unescaped %q survived into the projection:\n%s", bad, md)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"<script>", `\[x\]`, "&", ""q""} {
|
||||
if !strings.Contains(md, want) {
|
||||
t.Errorf("want escaped %q in:\n%s", want, md)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
|
||||
// headers), so the offer's pricing marker is replaced with nothing.
|
||||
func TestProjectOfferPricingEmpty(t *testing.T) {
|
||||
if got := projectOfferPricing(nil); got != "" {
|
||||
t.Errorf("empty catalog must project to empty string, got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -19,6 +20,13 @@ import (
|
||||
type Service struct {
|
||||
store *Store
|
||||
clock func() time.Time
|
||||
|
||||
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
|
||||
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
|
||||
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
|
||||
offerMu sync.Mutex
|
||||
offerMD string
|
||||
offerFresh bool
|
||||
}
|
||||
|
||||
// NewService constructs a Service over store with a wall-clock time source.
|
||||
|
||||
@@ -74,6 +74,9 @@ func (s *Server) registerRoutes() {
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
||||
u.POST("/wallet/reward", s.handleWalletReward)
|
||||
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
|
||||
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
|
||||
s.internal.GET("/offer/pricing", s.handleOfferPricing)
|
||||
}
|
||||
if s.payments != nil {
|
||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||
|
||||
@@ -41,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||
payments.SortAdminCatalog(products)
|
||||
var view adminconsole.CatalogView
|
||||
for _, p := range products {
|
||||
view.Products = append(view.Products, catalogRow(p))
|
||||
|
||||
@@ -112,6 +112,22 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, catalogDTOFrom(view))
|
||||
}
|
||||
|
||||
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
|
||||
// projected from the active products. The render sidecar fetches it and splices it into the offer
|
||||
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
|
||||
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
|
||||
// the steady state). Called by the renderer, not the gateway.
|
||||
func (s *Server) handleOfferPricing(c *gin.Context) {
|
||||
md, err := s.payments.OfferPricing(c.Request.Context())
|
||||
if err != nil {
|
||||
s.log.Error("offer pricing projection failed", zap.Error(err))
|
||||
c.String(http.StatusInternalServerError, "offer pricing unavailable")
|
||||
return
|
||||
}
|
||||
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
||||
c.String(http.StatusOK, md)
|
||||
}
|
||||
|
||||
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
||||
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
|
||||
// unconfigured), which gates the client's "watch for chips" button.
|
||||
|
||||
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
||||
TELEGRAM_TEST_ENV=false
|
||||
TELEGRAM_API_BASE_URL=
|
||||
|
||||
# --- Client-version gate (ARCHITECTURE.md §2) -------------------------------
|
||||
# The hard minimum + the soft recommended client build. Empty ⇒ dormant. Plain (unprefixed) Gitea
|
||||
# variables, shared across contours; in the test contour the stamped client version is a commit hash
|
||||
# (unparseable ⇒ fail-open), so these only enforce on real semver prod builds. Recommended must be ≥ min.
|
||||
GATEWAY_MIN_CLIENT_VERSION=
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION=
|
||||
|
||||
# --- VK Mini App ------------------------------------------------------------
|
||||
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||
|
||||
@@ -115,6 +115,11 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
||||
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
|
||||
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
|
||||
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
|
||||
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
|
||||
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
|
||||
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
|
||||
| `GATEWAY_MIN_CLIENT_VERSION` | variable | _(empty)_ | **Hard** tier of the client-version gate (ARCHITECTURE.md §2). Minimum client build the gateway will serve. Empty ⇒ **dormant** (every build served, the web default). Set it to the release `vMAJOR.MINOR.PATCH` in the **same** rollout that ships an incompatible wire change, so an older bundled APK is turned away with an *update required* signal instead of failing blind — the client then degrades to an offline "Update / Play offline" notice, not a hard lockout. Validated at load; a non-empty unparseable value fails startup. |
|
||||
| `GATEWAY_RECOMMENDED_CLIENT_VERSION` | variable | _(empty)_ | **Soft** tier of the client-version gate (ARCHITECTURE.md §2). A build at or above `GATEWAY_MIN_CLIENT_VERSION` but **below** this is served normally, but every gated `Execute` response carries an additive `X-Update-Recommended: 1` header ⇒ the client shows a dismissable *update available* nudge (play continues). Empty ⇒ off. Validated at load: unparseable, or **below** `GATEWAY_MIN_CLIENT_VERSION`, fails startup. Bump it (ahead of `MIN`) to nudge upgrades before a hard cut-over. |
|
||||
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
|
||||
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
|
||||
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||
@@ -233,6 +238,32 @@ redeploy the matching old tag. This dump is a belt-and-braces net for a bad migr
|
||||
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
||||
that survives losing the host.
|
||||
|
||||
## Android app build & release (RuStore)
|
||||
|
||||
The standalone Android app is built by a **manual** workflow, never automatically, and is separate from the
|
||||
web/prod rollout — a signed APK uploaded to RuStore by hand. Full plan: [`../ANDROID_PLAN.md`](../ANDROID_PLAN.md).
|
||||
|
||||
- **Trigger:** `Actions → android-build → Run workflow` from **`master`**, input `confirm=build` (mirrors
|
||||
`prod-deploy`). **Tag the release first** (`git tag vX.Y.Z` on `master`) — the workflow refuses anything
|
||||
but a clean `vMAJOR.MINOR.PATCH` and derives `versionCode = MA*1_000_000 + MI*1_000 + PA`,
|
||||
`versionName = MA.MI.PA`. Watch it green with `python3 ~/.claude/bin/gitea-ci-watch.py`.
|
||||
- **Output:** a `release APK` run artifact — **signed** when the signing secrets are present, an
|
||||
**unsigned** release APK otherwise (a dry run still proves the pipeline).
|
||||
- **Runner (host-executor):** JDK 21 comes from `setup-java`; the **Android SDK is host-provisioned** —
|
||||
install it once (`sdkmanager 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0'`) and grant the
|
||||
`runner` user read+exec (`sudo chmod -R a+rX /opt/android-sdk`). Override the path with the
|
||||
`ANDROID_SDK_DIR` variable. The workflow's `Verify the host Android SDK` step fails fast with the fix if
|
||||
the SDK is missing or unreadable.
|
||||
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
|
||||
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
|
||||
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
|
||||
variable to the store listing once published (empty until then — the in-app update button no-ops).
|
||||
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
|
||||
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
|
||||
away cleanly instead of failing blind.
|
||||
- **Upload:** download the artifact and upload it to RuStore by hand (no automated RuStore-API upload in the MVP).
|
||||
|
||||
## Point-in-time recovery (PITR)
|
||||
|
||||
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
||||
|
||||
@@ -107,6 +107,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
# The public offer page is rendered by the render sidecar: it splices the live catalog price
|
||||
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
|
||||
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
|
||||
# disjoint from the landing/app paths so the catch-all below never shadows it.
|
||||
@offer path /offer /offer/*
|
||||
handle @offer {
|
||||
reverse_proxy renderer:8090
|
||||
}
|
||||
|
||||
# Everything else — the public landing at / and any stray path — is static.
|
||||
handle {
|
||||
reverse_proxy landing:80
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
# docker compose -f docker-compose.bot.yml up -d
|
||||
#
|
||||
# The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's
|
||||
# published bot-link :9443 over mTLS. It exports no telemetry — otelcol lives on the
|
||||
# main host and is unreachable from here — so observe it via `docker logs` on this host.
|
||||
# published bot-link :9443 over mTLS. It exports no OTLP telemetry — otelcol lives on the
|
||||
# main host and is unreachable from here — but it reports its Bot API health up the bot-link,
|
||||
# which the gateway turns into metrics + alerts on the main host (docs/ARCHITECTURE.md); `docker
|
||||
# logs` on this host is the local detail view.
|
||||
# Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the
|
||||
# pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443.
|
||||
name: scrabble-bot
|
||||
@@ -50,7 +52,8 @@ services:
|
||||
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
|
||||
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
|
||||
# No telemetry export: otelcol is on the main host, unreachable from here.
|
||||
# No OTLP export: otelcol is on the main host, unreachable from here. Bot API health rides the
|
||||
# bot-link instead (the gateway exposes it as metrics); see docs/ARCHITECTURE.md.
|
||||
TELEGRAM_OTEL_TRACES_EXPORTER: none
|
||||
TELEGRAM_OTEL_METRICS_EXPORTER: none
|
||||
GOMAXPROCS: "1"
|
||||
|
||||
@@ -84,6 +84,9 @@ services:
|
||||
logging: *default-logging
|
||||
environment:
|
||||
RENDERER_PORT: "8090"
|
||||
# The offer page (GET /offer/) fetches the live catalog price list from the backend's internal
|
||||
# endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502.
|
||||
RENDERER_BACKEND_URL: http://backend:8080
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
@@ -219,6 +222,12 @@ services:
|
||||
GATEWAY_HTTP_ADDR: ":8081"
|
||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||
# Client-version gate (ARCHITECTURE.md §2): the hard minimum + the soft recommended client build.
|
||||
# Empty ⇒ dormant. Plain (unprefixed) — the same value serves every contour; in the test contour
|
||||
# the stamped client version is a commit hash (unparseable ⇒ fail-open), so the gate only bites
|
||||
# real semver builds in prod. Validated at gateway start (recommended must be ≥ min).
|
||||
GATEWAY_MIN_CLIENT_VERSION: ${GATEWAY_MIN_CLIENT_VERSION:-}
|
||||
GATEWAY_RECOMMENDED_CLIENT_VERSION: ${GATEWAY_RECOMMENDED_CLIENT_VERSION:-}
|
||||
# Telegram auth validates against the home validator (plaintext, internal).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
@@ -251,6 +260,12 @@ services:
|
||||
# secret; empty (unset secret) leaves the trap off.
|
||||
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
||||
# Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible
|
||||
# (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the
|
||||
# prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults.
|
||||
GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false}
|
||||
GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-}
|
||||
GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-}
|
||||
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
GATEWAY_SERVICE_NAME: scrabble-gateway
|
||||
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"uid": "scrabble-bot",
|
||||
"title": "Scrabble — Telegram bot",
|
||||
"tags": ["scrabble"],
|
||||
"timezone": "",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "30s",
|
||||
"time": { "from": "now-6h", "to": "now" },
|
||||
"panels": [
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Bot connected",
|
||||
"description": "botlink_connected_bots: bots currently holding the gateway bot-link (1 = healthy).",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "max(botlink_connected_bots)" }]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Last Bot API OK (age)",
|
||||
"description": "Seconds since the bot's most recent successful Bot API call. Grows unbounded if the bot wedges.",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "time() - max(bot_tg_last_ok_unix)" }]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Bot API errors/s by kind",
|
||||
"description": "bot_tg_errors_total by kind: connect (getUpdates), api (other sends), rate_limited (429). 429 should stay ~0.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "sum by (kind) (rate(bot_tg_errors_total[5m]))", "legendFormat": "{{kind}}" }]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Bot-link commands/s by result",
|
||||
"description": "botlink_commands_total by result: delivered / not_delivered / dropped / error. Sustained not_delivered or error means gateway sends are failing to reach Telegram.",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "sum by (result) (rate(botlink_commands_total[5m]))", "legendFormat": "{{result}}" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -53,6 +53,31 @@
|
||||
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Blocklist entries",
|
||||
"description": "CIDR ranges in the active community IP blocklist feed (0 when disabled or not yet fetched).",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 13 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_entries)" }]
|
||||
},
|
||||
{
|
||||
"type": "stat",
|
||||
"title": "Blocklist feed age",
|
||||
"description": "Seconds since the community IP blocklist feed was last successfully fetched. Grows if the fetch is failing; the feed is dropped fail-open once stale.",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 13 },
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_age_seconds)" }]
|
||||
},
|
||||
{
|
||||
"type": "timeseries",
|
||||
"title": "Blocklist blocks/s",
|
||||
"description": "Requests refused at the edge by the community IP blocklist (gateway_blocklist_blocked_total).",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
|
||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||
"targets": [{ "refId": "A", "expr": "sum(rate(gateway_blocklist_blocked_total[5m]))" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -108,6 +108,32 @@ groups:
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
|
||||
|
||||
# The community IP blocklist feed has not refreshed in over a day (the gateway re-fetches every
|
||||
# few hours). Absent/NaN-safe: the age gauge appears only once a feed has loaded, so a disabled
|
||||
# or never-fetched blocklist stays quiet. The feed itself is dropped (fail-open) at its
|
||||
# max-staleness window; this warns well before that so the operator can fix the fetch.
|
||||
- uid: blocklist_stale
|
||||
title: IP blocklist feed not refreshing
|
||||
condition: C
|
||||
for: 15m
|
||||
noDataState: OK
|
||||
execErrState: OK
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange: { from: 300, to: 0 }
|
||||
datasourceUid: prometheus
|
||||
model: { refId: A, expr: gateway_blocklist_age_seconds, instant: true }
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
refId: C
|
||||
type: threshold
|
||||
expression: A
|
||||
conditions:
|
||||
- evaluator: { type: gt, params: [86400] }
|
||||
labels: { severity: warning }
|
||||
annotations: { summary: 'The community IP blocklist feed has not refreshed in over 24h — the fetch is failing; it will be dropped (fail-open) once stale. Check the gateway blocklist refresher logs and the feed URL.' }
|
||||
|
||||
- orgId: 1
|
||||
name: scrabble-host
|
||||
folder: Alerts
|
||||
@@ -277,3 +303,91 @@ groups:
|
||||
- evaluator: { type: gt, params: [1800] }
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||
|
||||
# Remote Telegram bot health. The bot runs on its own host and exports no telemetry of its own; the
|
||||
# gateway observes it through the bot-link (botlink_connected_bots) and the health it reports over
|
||||
# that stream (bot_tg_*). All absent/NaN-safe: before a bot ever connects the metrics are absent, so
|
||||
# noDataState OK keeps them quiet on a fresh contour. The alert email does NOT go through the bot, so
|
||||
# a bot-down alert is deliverable.
|
||||
- orgId: 1
|
||||
name: scrabble-bot
|
||||
folder: Alerts
|
||||
interval: 1m
|
||||
rules:
|
||||
- uid: bot_disconnected
|
||||
title: Telegram bot disconnected
|
||||
condition: C
|
||||
for: 5m
|
||||
noDataState: OK
|
||||
execErrState: OK
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange: { from: 300, to: 0 }
|
||||
datasourceUid: prometheus
|
||||
model: { refId: A, expr: botlink_connected_bots, instant: true }
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
refId: C
|
||||
type: threshold
|
||||
expression: A
|
||||
conditions:
|
||||
- evaluator: { type: lt, params: [1] }
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: 'No Telegram bot is connected to the gateway bot-link — out-of-app push and admin sends are down. Check the bot host.' }
|
||||
|
||||
# Positive liveness: a bot is connected but its last successful Bot API call is over 5 minutes
|
||||
# old — the getUpdates long-poll returns every ~minute even when idle, so a stale stamp means the
|
||||
# bot is wedged (no errors, no traffic). Guarded by "and connected" so a mere disconnect (owned by
|
||||
# bot_disconnected) does not double-fire.
|
||||
- uid: bot_tg_stale
|
||||
title: Telegram bot not reaching the Bot API
|
||||
condition: C
|
||||
for: 5m
|
||||
noDataState: OK
|
||||
execErrState: OK
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange: { from: 600, to: 0 }
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
refId: A
|
||||
expr: (time() - bot_tg_last_ok_unix) and on() (botlink_connected_bots >= 1)
|
||||
instant: true
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
refId: C
|
||||
type: threshold
|
||||
expression: A
|
||||
conditions:
|
||||
- evaluator: { type: gt, params: [300] }
|
||||
labels: { severity: critical }
|
||||
annotations: { summary: 'A connected Telegram bot has not reached the Bot API in over 5 minutes — the update poll is wedged. Check the bot host and Telegram reachability.' }
|
||||
|
||||
# 429s should be ~never once the bot honours Retry-After; any sustained rate-limiting is a symptom
|
||||
# (a send loop, a misbehaving path) worth investigating.
|
||||
- uid: bot_tg_rate_limited
|
||||
title: Telegram bot rate-limited (429)
|
||||
condition: C
|
||||
for: 5m
|
||||
noDataState: OK
|
||||
execErrState: OK
|
||||
data:
|
||||
- refId: A
|
||||
relativeTimeRange: { from: 900, to: 0 }
|
||||
datasourceUid: prometheus
|
||||
model:
|
||||
refId: A
|
||||
expr: increase(bot_tg_errors_total{kind="rate_limited"}[15m])
|
||||
instant: true
|
||||
- refId: C
|
||||
datasourceUid: __expr__
|
||||
model:
|
||||
refId: C
|
||||
type: threshold
|
||||
expression: A
|
||||
conditions:
|
||||
- evaluator: { type: gt, params: [0] }
|
||||
labels: { severity: warning }
|
||||
annotations: { summary: 'The Telegram bot is being rate-limited (HTTP 429). It should honour Retry-After, so sustained 429s point to a send loop or a hot path.' }
|
||||
|
||||
@@ -18,18 +18,9 @@
|
||||
@shell not path /assets/*
|
||||
header @shell Cache-Control "no-cache"
|
||||
|
||||
# The static public offer page, rendered from ui/legal/offer_ru.md at build
|
||||
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
|
||||
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
|
||||
# landing shell below (whose index is landing.html). A bare /offer redirects in.
|
||||
handle /offer {
|
||||
redir * /offer/ permanent
|
||||
}
|
||||
handle /offer/* {
|
||||
file_server {
|
||||
index index.html
|
||||
}
|
||||
}
|
||||
# The public offer page (/offer/) is no longer served here: the contour caddy routes it to the
|
||||
# render sidecar, which splices the live catalog price list into ui/legal/offer_ru.md. This
|
||||
# container never sees /offer/, so it carries no offer assets (the vite emit-offer plugin is gone).
|
||||
|
||||
# An unknown path falls back to the landing shell (the gateway's old "/"
|
||||
# behaviour); "/" itself resolves through the index below.
|
||||
|
||||
@@ -49,6 +49,8 @@ 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'
|
||||
@@ -77,6 +79,11 @@ export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
|
||||
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
||||
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||
# Community IP blocklist (Spamhaus DROP): opt-in — the operator enables it and sets the feed URL +
|
||||
# allowlist via PROD_GATEWAY_BLOCKLIST_* vars once the feed is verified. Unset ⇒ off (safe).
|
||||
export GATEWAY_BLOCKLIST_ENABLED='${GATEWAY_BLOCKLIST_ENABLED:-false}'
|
||||
export GATEWAY_BLOCKLIST_URL='$GATEWAY_BLOCKLIST_URL'
|
||||
export GATEWAY_BLOCKLIST_ALLOW='$GATEWAY_BLOCKLIST_ALLOW'
|
||||
# Continuous WAL archiving (pgBackRest -> S3) for point-in-time recovery. The artifact
|
||||
# ships disarmed: PGBACKREST_ARCHIVE_MODE defaults off, so archiving stays inert until the
|
||||
# operator sets the S3 repository values + secrets, creates the stanza and flips the switch
|
||||
|
||||
@@ -100,15 +100,26 @@ dropped). Horizontal scaling is explicit future work.
|
||||
auth operations are unauthenticated and return the minted token. A unary
|
||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
||||
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
||||
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
||||
response was lost — its button is disabled while offline and the player re-issues it on
|
||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
||||
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
||||
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
||||
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
||||
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||
only **read-only** ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
|
||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
||||
local lobby, no transport kill switch and no device-local create paths.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -140,6 +151,40 @@ dropped). Horizontal scaling is explicit future work.
|
||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
||||
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||
platform-native push via the platform side-service.
|
||||
- **Client-version gate — two tiers** (native long-tail protection): a bundled native install (§13) can be
|
||||
arbitrarily old, so the client stamps its build into an **`X-Client-Version`** request header (the app
|
||||
version from `pkg/version` / `__APP_VERSION__`), read by the gateway **before it decodes the FlatBuffers
|
||||
payload**. The version rides the **outermost stable layer** — an HTTP header, never the FBS payload —
|
||||
because protobuf envelopes and headers are version-tolerant by design while the FBS payload is the layer
|
||||
that breaks. Enforcement is **per-call, not a Hello RPC** (`Execute`/`Subscribe` check it at the top,
|
||||
before registry lookup / auth / decode), so the first online call is already gated. Both tiers **fail
|
||||
open** (an absent or unparseable header passes) and are **dormant** until deliberately configured (both
|
||||
vars empty ⇒ off, web behaviour unchanged).
|
||||
- **Hard (critical) — `GATEWAY_MIN_CLIENT_VERSION`**: `version < min` ⇒ the domain envelope
|
||||
`result_code = "update_required"` (HTTP 200) on `Execute` and `CodeFailedPrecondition` on `Subscribe`;
|
||||
the client makes **zero** successful requests. Its reaction is **graceful, not terminal**: it enters
|
||||
`offlineVersionLocked` (offline mode) and shows a **dismissable notice** — **"Update"** (native → the
|
||||
store listing `VITE_RUSTORE_URL`, web → reload) or **"Play offline"** (dismiss → keep playing local
|
||||
vs_ai / hotseat). The lock is sticky until an actual update on the next launch.
|
||||
- **Soft (recommended) — `GATEWAY_RECOMMENDED_CLIENT_VERSION`**: `min ≤ version < recommended` ⇒ the
|
||||
gateway sets an **additive** `X-Update-Recommended: 1` **response header** on the served `Execute`
|
||||
response (the call still succeeds); a transport interceptor turns it into a **dismissable "update
|
||||
available" nudge** in the lobby — play continues, nothing blocks. `recommended` must be **≥ `min`**
|
||||
(validated at config load); empty ⇒ the soft tier is off.
|
||||
- **Frozen wire contract** (so any build, however old, can always recognise "update required"): three
|
||||
things are permanent — (1) the protobuf envelope field numbers in `edge.proto` are never renumbered or
|
||||
reused; (2) the `update_required` sentinel — the `result_code` string **and** the `FailedPrecondition`
|
||||
code — never changes; (3) the FBS schema stays **additive** (append trailing fields; `(deprecated)`, never
|
||||
delete — deleting shifts field IDs and breaks older readers). A breaking wire change happens only *inside*
|
||||
the FBS payload, which is exactly what the gate guards. **Deploy discipline:** the production rollout that
|
||||
ships an incompatible wire change **also** bumps `GATEWAY_MIN_CLIENT_VERSION` to that release, in the same
|
||||
rollout (see [`../deploy/README.md`](../deploy/README.md)).
|
||||
- **Gate × offline** (UX rule): offline is the net-state machine's `offlineNoNetwork` / `offlineVersionLocked`
|
||||
(implicit — no toggle) and its kill switch refuses calls, so the hard gate never fires *while already
|
||||
offline* — an old install always plays local vs_ai / hotseat. A hard `update_required` on a
|
||||
**user-initiated online call** degrades to `offlineVersionLocked` + the dismissable notice (above); the
|
||||
silent background guest reconciliation (§3) **swallows** an `update_required` and stays a local guest
|
||||
rather than interrupting local play.
|
||||
|
||||
## 3. Authentication & sessions
|
||||
|
||||
@@ -224,6 +269,19 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
||||
Platform and email users are auto-provisioned **durable** accounts with an
|
||||
identity.
|
||||
- **Local guest vs. server guest** (native offline-first, §13). The **native** app splits the local-play
|
||||
identity from the server account. A **local guest** is device-local with **no DB row**: a device-generated
|
||||
id + the localized default name (*Guest* / *Гость*) persisted on the device, existing from the very first
|
||||
launch with no network. It fills the human seat in a local vs_ai game and is the "you" for device-local
|
||||
games; a purely-offline user never consumes a server row. The **server guest** is the durable `is_guest`
|
||||
row above — minted **lazily** via `auth.guest` the first time the app reaches the network, its session
|
||||
cached and reused (exactly one per device, guarded by the cached session so it never double-mints). It
|
||||
unlocks online features (matchmaking, friends). **Reconciliation:** on gaining network with no server
|
||||
session the native app silently `auth.guest`s in the background and adopts it — best-effort, swallowing an
|
||||
`update_required` to stay a local guest rather than interrupting play (the gate × offline rule, §2).
|
||||
**Local games stay device-only** (both local vs_ai and hotseat persist only on the device); an identity
|
||||
transition never migrates them. Web/PWA/Telegram/VK are unchanged — they have no local guest and keep the
|
||||
prior online-session rule.
|
||||
|
||||
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
|
||||
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
||||
@@ -909,6 +967,22 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl
|
||||
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
||||
image option is not offered.
|
||||
|
||||
The same sidecar also serves the **public offer page** at `/offer/` — the one edge-exposed
|
||||
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared
|
||||
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited
|
||||
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it
|
||||
fetches the two catalog tables as markdown from the backend's internal
|
||||
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
|
||||
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
|
||||
page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served
|
||||
render issues no query in the steady state and the page always reflects the current catalog without
|
||||
a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. Packs are
|
||||
ordered by ascending rouble price; values are grouped by what they grant — hints only, then no-ads
|
||||
only, then no-ads + hints, then (reserved, empty today) products carrying the **tournament** atom,
|
||||
which becomes a fourth group once the tournament economy makes them sellable — and ordered by
|
||||
ascending chip price within each group. Product titles are admin input, so the projection escapes
|
||||
them (HTML entities + markdown metacharacters) before they reach the un-sanitised renderer.
|
||||
|
||||
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
|
||||
exchanges alphabet indices, but the persisted journal (and everything derived from it —
|
||||
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
|
||||
@@ -999,6 +1073,20 @@ answering `/start` with a URL button into the **main** bot's Mini App (`?startap
|
||||
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
|
||||
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
|
||||
|
||||
Because the bot **exports no telemetry of its own** (the OTel collector is on the main host,
|
||||
unreachable from the bot host), it reports its **Bot API health** up the same stream: a periodic
|
||||
`Health` message (`platform/telegram/internal/health`) carries delta counts of connect failures (the
|
||||
getUpdates long-poll), other API errors and 429s, plus the wall-clock second of its last successful
|
||||
Bot API call. The bot observes these **centrally by wrapping the Bot API HTTP client** — one place,
|
||||
no per-call-site instrumentation — and there also honours a 429's `Retry-After` (bounded) so it backs
|
||||
off rather than hammering (a 429 should therefore stay ~0). The gateway folds each report into its
|
||||
own metrics (`bot_tg_errors_total{kind}`, the `bot_tg_last_ok_unix` liveness gauge). The remote bot
|
||||
is thus monitored from the main host's Grafana with three layered signals: the **connection gauge**
|
||||
(`botlink_connected_bots`) catches a link/host/process outage, the **liveness gauge** catches a
|
||||
silently wedged bot (its stamp stays fresh even when idle, since getUpdates returns every poll), and
|
||||
**`botlink_commands_total{result}`** catches gateway sends failing to reach Telegram. Alerts route to
|
||||
the operator email, which does not depend on the bot.
|
||||
|
||||
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
|
||||
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
|
||||
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
|
||||
@@ -1057,7 +1145,9 @@ link — misses the event; while an add-email confirmation is pending the client
|
||||
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
|
||||
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
|
||||
and the gateway↔validator and bot-link calls; the gateway also exports
|
||||
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link. The OTLP **Collector** (OTLP/gRPC → Prometheus
|
||||
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link, plus the remote
|
||||
bot's own Bot API health it relays over the stream — `bot_tg_errors_total` (by kind) and the
|
||||
`bot_tg_last_ok_unix` liveness gauge (see the bot-link section). The OTLP **Collector** (OTLP/gRPC → Prometheus
|
||||
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
|
||||
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
||||
are stood up with the deploy (`deploy/`); the default exporter stays
|
||||
@@ -1174,6 +1264,18 @@ link — misses the event; while an add-email confirmation is pending the client
|
||||
(`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies
|
||||
the operator unbans the response returns, so a manual unban takes effect within the sync
|
||||
interval.
|
||||
- **Community IP blocklist (prod-only):** with `GATEWAY_BLOCKLIST_ENABLED` set, the gateway also
|
||||
refuses a client whose IP is in a curated CIDR feed (Spamhaus DROP, `GATEWAY_BLOCKLIST_URL`) with
|
||||
**403** in the same `abuseGuard`, before the fail2ban list. A background refresher re-fetches the
|
||||
feed every few hours (bounded fetch + size cap) into a sorted-range matcher (`ratelimit.Blocklist`,
|
||||
binary search, IPv4 only — an IPv6 client is never blocked here). It is **fault-tolerant and
|
||||
fail-open**: a failed fetch keeps the last-good feed, and once the feed is older than
|
||||
`GATEWAY_BLOCKLIST_MAX_STALENESS` it is **dropped** rather than block a legitimate client on a
|
||||
frozen list; a `GATEWAY_BLOCKLIST_ALLOW` allowlist (own infra, monitoring) is never blocked. Off by
|
||||
default and prod-only for the same real-client-IP reason as the ban. It is a **separate** static
|
||||
CIDR set, not the per-IP fail2ban store (a bulk CIDR feed cannot expand into per-IP entries).
|
||||
Observability: `gateway_blocklist_blocked_total`, the `gateway_blocklist_entries` size gauge and the
|
||||
`gateway_blocklist_age_seconds` staleness gauge (a Grafana alert warns before the feed is dropped).
|
||||
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
||||
database answers a bounded ping and the session cache is warmed).
|
||||
- The backend serves a **second listener** — a gRPC server
|
||||
@@ -1288,13 +1390,18 @@ route (its `directoryIndex` is `index.html`) would otherwise shadow it and serve
|
||||
cache-first — the old build's version until a second load. The landing page and the conditional
|
||||
polyfill bundle are excluded, and the Connect stream and runtime API POSTs are never precached nor
|
||||
intercepted, so the live app is never served stale. This satisfies Chromium's installability requirement (a registered SW, needed
|
||||
for install on Android) and powers the opt-in **offline mode** (in progress): a deliberate, device-scoped Settings
|
||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
||||
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
||||
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
||||
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
|
||||
friends* carries an **online/offline segmented control** — online = a friend invite, offline = **local
|
||||
pass-and-play (hotseat)** — with the online segment disabled and offline forced when there is no network;
|
||||
the online-only Telegram/VK mini-apps skip the segment and show the remote invite alone. A `vs_ai` or
|
||||
hotseat create is guarded when
|
||||
the chosen variant's dictionary is not available offline. A device-local `vs_ai` game is created through
|
||||
the in-browser engine and driven by the same game screen, the robot replying locally.
|
||||
A hotseat game is a device-local **2-4 human** game built through the same engine (`hotseat` +
|
||||
per-seat/host PIN locks on the record; the seats carry names but no accounts). A **mandatory host
|
||||
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||
@@ -1322,36 +1429,27 @@ eligible installed PWA (standalone web + confirmed email) **background-preloads*
|
||||
— on lobby entry and on a variant-preference change — through the same three-tier loader, retried
|
||||
with backoff and honouring the session miss-breaker; the move generator, the loader and the preload
|
||||
orchestration stay in lazy chunks. A first-lobby preload failure shows a *poor-connection* notice in
|
||||
the ad-banner slot. Flipping the Settings toggle **to** offline runs that same cache-first fetch
|
||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
||||
mode** boots from the persisted session and
|
||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
||||
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
||||
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
||||
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
||||
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
||||
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
||||
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
||||
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
||||
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
||||
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
||||
while offline. The gateway registers the `.webmanifest` MIME type
|
||||
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
||||
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
||||
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
||||
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
||||
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
||||
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
||||
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
||||
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
||||
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
|
||||
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
|
||||
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
|
||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
||||
`immutable` (a relaunch is a cache hit, not a re-download); the HTML shells are
|
||||
`no-cache` so a new deploy is picked up — both containers apply the same caching. An
|
||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
|
||||
catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
|
||||
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
|
||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/`
|
||||
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
|
||||
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
|
||||
container. The
|
||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
|
||||
@@ -1423,6 +1521,24 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
||||
contour activates; the prod main host omits it.
|
||||
|
||||
**Native Android build (Capacitor).** The SPA is also packaged as a standalone **Android app** (Capacitor 8,
|
||||
appId `ru.eruditgame.app`, "Эрудит"; minSdk 24, compile/targetSdk 36, JDK 21), first for **RuStore**. It is
|
||||
a **bundle** model — the WebView loads the packaged `dist/` from app assets (**no `server.url`**), so there
|
||||
is no OTA: updates ship through the store, and the **client-version gate** (§2) turns away a build too old to
|
||||
speak the current wire contract. Because the packaged origin is `file://`, the native build talks to an
|
||||
**absolute** gateway origin (`VITE_GATEWAY_URL` = the production origin; `lib/origin.ts` centralises how absolute URLs are built), and the service worker is skipped (the bundle is
|
||||
the cache). It is **offline-first**:
|
||||
`ui/scripts/bundle-dicts.mjs` copies the versioned `scrabble-dictionary` release DAWGs into
|
||||
`dist/dict/<variant>@<version>.dawg` (keyed on `VITE_DICT_VERSION`), so a cold first launch with no network
|
||||
enters as a **local guest** (§3) and plays local vs_ai / hotseat from the bundled dictionaries. Purchases are
|
||||
hidden in the MVP (`VITE_PAYMENTS_DISABLED`). The APK is built by a **manual** `workflow_dispatch` workflow
|
||||
(`.gitea/workflows/android-build.yaml`, from `master`, `confirm=build`) that builds the native SPA, bundles
|
||||
the dicts, and assembles a **release APK** artifact — signed when the `ANDROID_KEYSTORE_*` secrets are
|
||||
present, unsigned otherwise. `versionCode`/`versionName` are deterministic from the release tag `vMA.MI.PA`
|
||||
(`versionCode = MA*1_000_000 + MI*1_000 + PA`, `versionName = "MA.MI.PA"`). The runner is a host-executor
|
||||
with a host-provisioned Android SDK; RuStore upload is manual. Full plan + runbook:
|
||||
[`../ANDROID_PLAN.md`](../ANDROID_PLAN.md), [`../deploy/README.md`](../deploy/README.md).
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
- **Two long-lived branches**: **`development`** is the integration
|
||||
|
||||
@@ -30,7 +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.
|
||||
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
|
||||
@@ -273,22 +277,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
||||
statistics.
|
||||
|
||||
### Offline mode
|
||||
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 **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.
|
||||
|
||||
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
|
||||
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
|
||||
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**.
|
||||
@@ -301,14 +311,31 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Social: friends, block, chat, nudge
|
||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||
|
||||
@@ -32,7 +32,12 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||
`noindex` — индексируется только посадочная страница.
|
||||
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
|
||||
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
|
||||
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
|
||||
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
|
||||
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
|
||||
сейчас в продаже — без передеплоя.
|
||||
|
||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
||||
@@ -278,22 +283,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||
|
||||
### Офлайн-режим
|
||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
||||
сохраняется между запусками.
|
||||
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
|
||||
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
|
||||
приложениям). Когда оно не может достучаться
|
||||
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||
сети.
|
||||
|
||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
||||
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
||||
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
||||
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
||||
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
||||
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
||||
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
||||
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||
|
||||
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
|
||||
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
|
||||
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||
@@ -307,14 +317,32 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||
своего хода.
|
||||
|
||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
||||
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
||||
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
||||
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
||||
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
||||
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
||||
|
||||
### Нативное приложение (Android)
|
||||
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
|
||||
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
|
||||
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
|
||||
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
|
||||
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
|
||||
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
|
||||
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
|
||||
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
|
||||
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
|
||||
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
|
||||
офлайн-игры.
|
||||
|
||||
### Социальное: друзья, блок, чат, nudge
|
||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||
|
||||
@@ -17,21 +17,43 @@ 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 and the GCG share/copy/download choice, plus Playwright specs against the
|
||||
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
|
||||
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: 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
|
||||
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
|
||||
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.
|
||||
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.
|
||||
- **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
|
||||
@@ -175,6 +197,16 @@ 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
|
||||
|
||||
|
||||
@@ -42,10 +42,15 @@ 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 (the workspace file carries pnpm's build-script
|
||||
# approval for esbuild), then build. Committed src/gen/ means no codegen here.
|
||||
# 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).
|
||||
COPY ui/package.json ui/pnpm-lock.yaml ui/pnpm-workspace.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
COPY ui ./
|
||||
RUN pnpm build
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
// blocklistFetchTimeout bounds one feed fetch.
|
||||
blocklistFetchTimeout = 30 * time.Second
|
||||
// maxBlocklistBytes caps the fetched feed (Spamhaus DROP is well under 1 MiB; the cap stops a
|
||||
// hostile or misconfigured URL from streaming unbounded data into memory).
|
||||
maxBlocklistBytes = 8 << 20
|
||||
)
|
||||
|
||||
// parseAllowlist parses the never-block entries (CIDRs or bare IPs, a bare IP read as a /32) into
|
||||
// networks. A malformed entry is a fatal config error — the operator must fix it, not silently lose
|
||||
// a protected range.
|
||||
func parseAllowlist(entries []string) ([]*net.IPNet, error) {
|
||||
out := make([]*net.IPNet, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
s := strings.TrimSpace(e)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(s, "/") {
|
||||
s += "/32"
|
||||
}
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gateway: GATEWAY_BLOCKLIST_ALLOW: %q: %w", e, err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runBlocklistRefresher keeps the community IP blocklist feed current: it fetches immediately, then
|
||||
// every cfg.Refresh, and applies each outcome through ratelimit.ApplyRefresh (keep-last-good on a
|
||||
// transient failure; drop the feed once it goes stale, fail-open). It returns when ctx is cancelled.
|
||||
func runBlocklistRefresher(ctx context.Context, bl *ratelimit.Blocklist, cfg config.BlocklistConfig, log *zap.Logger) {
|
||||
client := &http.Client{Timeout: blocklistFetchTimeout}
|
||||
for {
|
||||
cidrs, err := fetchBlocklist(ctx, client, cfg.URL)
|
||||
switch ratelimit.ApplyRefresh(bl, cidrs, err, time.Now(), cfg.MaxStaleness) {
|
||||
case ratelimit.RefreshUpdated:
|
||||
log.Info("blocklist refreshed", zap.String("url", cfg.URL), zap.Int("entries", bl.Len()))
|
||||
case ratelimit.RefreshKept:
|
||||
log.Warn("blocklist refresh failed; keeping the last-good feed", zap.Error(err))
|
||||
case ratelimit.RefreshDropped:
|
||||
log.Warn("blocklist refresh failed and the feed is stale; dropped it (fail-open)", zap.Error(err))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(cfg.Refresh):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchBlocklist GETs the feed and parses it, bounded by the fetch timeout and the size cap.
|
||||
func fetchBlocklist(ctx context.Context, client *http.Client, url string) ([]*net.IPNet, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gateway: blocklist fetch %s: status %d", url, resp.StatusCode)
|
||||
}
|
||||
return ratelimit.ParseDROP(io.LimitReader(resp.Body, maxBlocklistBytes))
|
||||
}
|
||||
@@ -129,6 +129,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
Window: cfg.Abuse.BanWindow,
|
||||
Duration: cfg.Abuse.BanDuration,
|
||||
})
|
||||
allow, err := parseAllowlist(cfg.Blocklist.Allow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blocklist := ratelimit.NewBlocklist(cfg.Blocklist.Enabled, allow)
|
||||
hub := push.NewHub(0)
|
||||
|
||||
var validator transcode.TelegramValidator
|
||||
@@ -216,21 +221,24 @@ 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,
|
||||
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,
|
||||
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,
|
||||
})
|
||||
|
||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||
@@ -243,6 +251,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
if cfg.Abuse.BanEnabled {
|
||||
go runBanSync(ctx, banlist, backend, logger)
|
||||
}
|
||||
// When the edge blocklist is enabled (prod), keep the community feed refreshed.
|
||||
if cfg.Blocklist.Enabled {
|
||||
go runBlocklistRefresher(ctx, blocklist, cfg.Blocklist, logger)
|
||||
}
|
||||
|
||||
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
|
||||
servers := []*namedServer{{name: "public", srv: public}}
|
||||
|
||||
@@ -76,6 +76,11 @@ type Hub struct {
|
||||
|
||||
connected metric.Int64UpDownCounter
|
||||
commands metric.Int64Counter
|
||||
// tgErrors counts the remote bot's Bot API failures it reports over the stream, by kind
|
||||
// (connect / api / rate_limited). lastOK holds the wall-clock second of the bot's most recent
|
||||
// successful Bot API call, read by the bot_tg_last_ok_unix observable gauge for a staleness alert.
|
||||
tgErrors metric.Int64Counter
|
||||
lastOK atomic.Int64
|
||||
}
|
||||
|
||||
// link is one connected bot's outbound queue and identity.
|
||||
@@ -103,6 +108,19 @@ func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *H
|
||||
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
|
||||
h.commands, _ = meter.Int64Counter("botlink_commands_total",
|
||||
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
|
||||
h.tgErrors, _ = meter.Int64Counter("bot_tg_errors_total",
|
||||
metric.WithDescription("Telegram Bot API failures the remote bot reports over the bot-link, by kind (connect, api, rate_limited)."))
|
||||
// The bot's last successful Bot API second, reported over the stream. An observable gauge
|
||||
// reads the atomic on each collection; it observes nothing until a bot has reported, so a
|
||||
// never-connected gateway shows no data (the connected-bots alert covers that case).
|
||||
_, _ = meter.Int64ObservableGauge("bot_tg_last_ok_unix",
|
||||
metric.WithDescription("Unix second of the remote bot's most recent successful Bot API call (0/absent until reported)."),
|
||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||
if v := h.lastOK.Load(); v > 0 {
|
||||
o.Observe(v)
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -149,6 +167,36 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.
|
||||
if ack := msg.GetAck(); ack != nil {
|
||||
h.resolve(ack)
|
||||
}
|
||||
if hb := msg.GetHealth(); hb != nil {
|
||||
h.recordHealth(hb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordHealth folds one bot Health report into the gateway's cumulative Bot API metrics: the three
|
||||
// delta counters are added under their kind label, and the last-ok stamp (an absolute wall-clock
|
||||
// second) advances the liveness gauge (monotonically — a stale reordered report cannot rewind it).
|
||||
func (h *Hub) recordHealth(hb *botlinkv1.Health) {
|
||||
if h.tgErrors != nil {
|
||||
ctx := context.Background()
|
||||
if v := hb.GetConnectFailures(); v > 0 {
|
||||
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "connect")))
|
||||
}
|
||||
if v := hb.GetApiErrors(); v > 0 {
|
||||
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "api")))
|
||||
}
|
||||
if v := hb.GetRateLimited(); v > 0 {
|
||||
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "rate_limited")))
|
||||
}
|
||||
}
|
||||
for {
|
||||
cur := h.lastOK.Load()
|
||||
if hb.GetLastOkUnix() <= cur {
|
||||
break
|
||||
}
|
||||
if h.lastOK.CompareAndSwap(cur, hb.GetLastOkUnix()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,3 +233,21 @@ func TestRelayServerNoBot(t *testing.T) {
|
||||
t.Fatal("expected an error with no bot connected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordHealthLastOKMonotonic checks a bot Health report advances the last-ok liveness stamp but
|
||||
// a stale or reordered report (an earlier second) never rewinds it.
|
||||
func TestRecordHealthLastOKMonotonic(t *testing.T) {
|
||||
hub := NewHub(nil, nil, nil)
|
||||
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 100})
|
||||
if got := hub.lastOK.Load(); got != 100 {
|
||||
t.Fatalf("lastOK = %d, want 100", got)
|
||||
}
|
||||
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 90}) // reordered/stale — must not rewind
|
||||
if got := hub.lastOK.Load(); got != 100 {
|
||||
t.Errorf("lastOK rewound to %d, want 100", got)
|
||||
}
|
||||
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 150})
|
||||
if got := hub.lastOK.Load(); got != 150 {
|
||||
t.Errorf("lastOK = %d, want 150", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"scrabble/gateway/internal/clientver"
|
||||
pkgtel "scrabble/pkg/telemetry"
|
||||
)
|
||||
|
||||
@@ -53,10 +55,22 @@ 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).
|
||||
Abuse AbuseConfig
|
||||
// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
|
||||
Blocklist BlocklistConfig
|
||||
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
||||
Telemetry pkgtel.Config
|
||||
}
|
||||
@@ -166,6 +180,42 @@ func DefaultAbuse() AbuseConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
|
||||
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
|
||||
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
|
||||
// test contour). Only IPv4 is matched.
|
||||
type BlocklistConfig struct {
|
||||
// Enabled turns edge blocklisting on. Off by default (prod-only).
|
||||
Enabled bool
|
||||
// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
|
||||
// operator sets it explicitly so no feed URL is assumed.
|
||||
URL string
|
||||
// Refresh is how often the feed is re-fetched.
|
||||
Refresh time.Duration
|
||||
// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
|
||||
// (fail-open — a legitimate client is never blocked on a frozen feed).
|
||||
MaxStaleness time.Duration
|
||||
// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
|
||||
// the feed can never block. Parsed at startup.
|
||||
Allow []string
|
||||
}
|
||||
|
||||
// Blocklist defaults; the feed URL has no default (the operator sets it).
|
||||
const (
|
||||
defaultBlocklistRefresh = 6 * time.Hour
|
||||
defaultBlocklistMaxStaleness = 48 * time.Hour
|
||||
)
|
||||
|
||||
// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
|
||||
// the agreed refresh / staleness windows.
|
||||
func DefaultBlocklist() BlocklistConfig {
|
||||
return BlocklistConfig{
|
||||
Enabled: false,
|
||||
Refresh: defaultBlocklistRefresh,
|
||||
MaxStaleness: defaultBlocklistMaxStaleness,
|
||||
}
|
||||
}
|
||||
|
||||
// VKIDConfig holds the VK ID web-login credentials for the confidential
|
||||
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
|
||||
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
|
||||
@@ -187,14 +237,16 @@ 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"),
|
||||
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"),
|
||||
VKID: VKIDConfig{
|
||||
AppID: os.Getenv("GATEWAY_VK_ID_APP_ID"),
|
||||
ClientSecret: os.Getenv("GATEWAY_VK_ID_CLIENT_SECRET"),
|
||||
@@ -203,6 +255,7 @@ func Load() (Config, error) {
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
Abuse: DefaultAbuse(),
|
||||
Blocklist: DefaultBlocklist(),
|
||||
BotLink: BotLinkConfig{
|
||||
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
|
||||
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
|
||||
@@ -244,6 +297,17 @@ func Load() (Config, error) {
|
||||
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
|
||||
if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
|
||||
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -281,9 +345,29 @@ 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")
|
||||
}
|
||||
if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
|
||||
return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED")
|
||||
}
|
||||
if c.BotLink.Addr != "" {
|
||||
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
|
||||
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
|
||||
@@ -304,6 +388,21 @@ func envOr(key, fallback string) string {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// splitList splits a comma-separated environment value into trimmed, non-empty items.
|
||||
func splitList(v string) []string {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(v, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// envBool parses the environment variable named key as a bool, returning fallback
|
||||
// when it is unset and an error when it is set but malformed.
|
||||
func envBool(key string, fallback bool) (bool, error) {
|
||||
|
||||
@@ -47,6 +47,65 @@ 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) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package connectsrv_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -24,7 +25,7 @@ const honeypotHeader = "X-Scrabble-Honeypot"
|
||||
|
||||
// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake
|
||||
// backend, returning the front URL, a Connect client and a cleanup func.
|
||||
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
|
||||
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
|
||||
t.Helper()
|
||||
backendSrv := httptest.NewServer(backendHandler)
|
||||
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
|
||||
@@ -36,6 +37,7 @@ func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits
|
||||
Sessions: session.NewCache(backend, time.Minute, 100),
|
||||
Limiter: ratelimit.New(),
|
||||
Banlist: bl,
|
||||
Blocklist: block,
|
||||
Honeytoken: honeytoken,
|
||||
Hub: push.NewHub(0),
|
||||
RateLimit: limits,
|
||||
@@ -69,7 +71,7 @@ func enabledBanlist(threshold int) *ratelimit.Banlist {
|
||||
func TestAbuseGuardBlocksBannedIP(t *testing.T) {
|
||||
bl := enabledBanlist(100)
|
||||
bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire)
|
||||
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := noRedirect().Get(url + "/")
|
||||
@@ -86,7 +88,7 @@ func TestAbuseGuardBlocksBannedIP(t *testing.T) {
|
||||
// and bans the client IP, so the next request is blocked.
|
||||
func TestHoneypotHeaderTrips(t *testing.T) {
|
||||
bl := enabledBanlist(100)
|
||||
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
||||
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called for a honeypot hit")
|
||||
})
|
||||
defer cleanup()
|
||||
@@ -118,7 +120,7 @@ func TestHoneypotHeaderTrips(t *testing.T) {
|
||||
// banlist still 404s the decoy (detection/logging) but bans nothing.
|
||||
func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) {
|
||||
bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled
|
||||
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||
defer cleanup()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
|
||||
@@ -150,7 +152,7 @@ func TestPublicRejectionStrikesBan(t *testing.T) {
|
||||
bl := enabledBanlist(1)
|
||||
limits := config.DefaultRateLimit()
|
||||
limits.PublicPerMinute, limits.PublicBurst = 1, 1
|
||||
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
@@ -173,7 +175,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
|
||||
bl := enabledBanlist(1)
|
||||
limits := config.DefaultRateLimit()
|
||||
limits.UserPerMinute, limits.UserBurst = 1, 1
|
||||
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v1/internal/sessions/resolve":
|
||||
_, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`))
|
||||
@@ -202,7 +204,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
|
||||
// caller and returns the ordinary invalid-session error without a backend call.
|
||||
func TestHoneytokenBansAndRejects(t *testing.T) {
|
||||
bl := enabledBanlist(100)
|
||||
_, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
||||
_, client, cleanup := guardedEdge(t, bl, nil, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("backend must not be called for the honeytoken")
|
||||
})
|
||||
defer cleanup()
|
||||
@@ -217,3 +219,25 @@ func TestHoneytokenBansAndRejects(t *testing.T) {
|
||||
t.Fatal("the honeytoken must ban the caller")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAbuseGuardBlocksBlocklistedIP verifies a client IP in the community blocklist feed is refused
|
||||
// with 403 at the HTTP layer, before any handler runs.
|
||||
func TestAbuseGuardBlocksBlocklistedIP(t *testing.T) {
|
||||
block := ratelimit.NewBlocklist(true, nil)
|
||||
_, feed, err := net.ParseCIDR("127.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
block.SetCIDRs([]*net.IPNet{feed}, time.Now())
|
||||
url, _, cleanup := guardedEdge(t, nil, block, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
|
||||
defer cleanup()
|
||||
|
||||
resp, err := noRedirect().Get(url + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("blocklisted GET / = %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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.
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/metric"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
)
|
||||
|
||||
// meterName scopes the gateway edge's OpenTelemetry instruments.
|
||||
@@ -27,6 +29,7 @@ type serverMetrics struct {
|
||||
edge metric.Float64Histogram
|
||||
rateLimited metric.Int64Counter
|
||||
banned metric.Int64Counter
|
||||
blocklisted metric.Int64Counter
|
||||
active *activeUsers
|
||||
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
|
||||
localColdStart metric.Int64Counter
|
||||
@@ -40,7 +43,7 @@ type serverMetrics struct {
|
||||
// falling back to a no-op histogram on the (rare) construction error. The
|
||||
// active_users gauge is registered as an observable callback over the in-memory
|
||||
// tracker.
|
||||
func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||
func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetrics {
|
||||
if meter == nil {
|
||||
meter = noop.NewMeterProvider().Meter(meterName)
|
||||
}
|
||||
@@ -68,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||
}
|
||||
m := &serverMetrics{
|
||||
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
|
||||
blocklisted: counterOf(meter, "gateway_blocklist_blocked_total",
|
||||
"Requests refused at the edge by the community IP blocklist (Spamhaus DROP)."),
|
||||
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
|
||||
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
|
||||
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
|
||||
@@ -90,6 +95,25 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
|
||||
return nil
|
||||
}, gauge)
|
||||
}
|
||||
|
||||
// Community blocklist status: the feed size and how stale it is, for the dashboard and the
|
||||
// "feed not refreshing" alert. Age is observed only once a feed has loaded, so a disabled or
|
||||
// never-fetched blocklist reports no age (and entries 0).
|
||||
if bl != nil {
|
||||
entries, e1 := meter.Int64ObservableGauge("gateway_blocklist_entries",
|
||||
metric.WithDescription("CIDR ranges in the active community IP blocklist feed."))
|
||||
age, e2 := meter.Float64ObservableGauge("gateway_blocklist_age_seconds",
|
||||
metric.WithDescription("Seconds since the community IP blocklist feed was last successfully fetched (absent until the first fetch)."))
|
||||
if e1 == nil && e2 == nil {
|
||||
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
|
||||
o.ObserveInt64(entries, int64(bl.Len()))
|
||||
if last := bl.LastFetch(); !last.IsZero() {
|
||||
o.ObserveFloat64(age, time.Since(last).Seconds())
|
||||
}
|
||||
return nil
|
||||
}, entries, age)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -118,6 +142,11 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
|
||||
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||
}
|
||||
|
||||
// recordBlocklistBlock counts one request refused by the community IP blocklist.
|
||||
func (m *serverMetrics) recordBlocklistBlock(ctx context.Context) {
|
||||
m.blocklisted.Add(ctx, 1)
|
||||
}
|
||||
|
||||
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
|
||||
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
|
||||
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestEdgeMetric(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
m := newServerMetrics(meter)
|
||||
m := newServerMetrics(meter, nil)
|
||||
|
||||
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
|
||||
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
|
||||
@@ -74,7 +74,7 @@ func TestRateLimitedMetric(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
m := newServerMetrics(meter)
|
||||
m := newServerMetrics(meter, nil)
|
||||
|
||||
m.recordRateLimited(ctx, "user")
|
||||
m.recordRateLimited(ctx, "user")
|
||||
@@ -112,7 +112,7 @@ func TestBannedMetric(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
m := newServerMetrics(meter)
|
||||
m := newServerMetrics(meter, nil)
|
||||
|
||||
m.recordBan(ctx, "tripwire")
|
||||
m.recordBan(ctx, "tripwire")
|
||||
@@ -150,7 +150,7 @@ func TestUnsupportedEngineMetric(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := sdkmetric.NewManualReader()
|
||||
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
|
||||
m := newServerMetrics(meter)
|
||||
m := newServerMetrics(meter, nil)
|
||||
|
||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
|
||||
|
||||
@@ -26,6 +26,7 @@ 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"
|
||||
@@ -46,6 +47,21 @@ 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 (
|
||||
@@ -77,6 +93,7 @@ type Server struct {
|
||||
limiter *ratelimit.Limiter
|
||||
tracker *ratelimit.Tracker
|
||||
banlist *ratelimit.Banlist
|
||||
blocklist *ratelimit.Blocklist
|
||||
honeytoken string
|
||||
vkAppSecret string
|
||||
hub *push.Hub
|
||||
@@ -87,6 +104,17 @@ 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
|
||||
@@ -109,6 +137,9 @@ type Deps struct {
|
||||
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
|
||||
// (inert) banlist.
|
||||
Banlist *ratelimit.Banlist
|
||||
// Blocklist enforces the community IP blocklist on the hot path; nil selects a
|
||||
// disabled (inert) blocklist.
|
||||
Blocklist *ratelimit.Blocklist
|
||||
// Honeytoken, when non-empty, is the planted bearer value whose presentation
|
||||
// bans the caller and raises a high-severity alarm.
|
||||
Honeytoken string
|
||||
@@ -124,6 +155,13 @@ 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.
|
||||
@@ -148,10 +186,38 @@ func NewServer(d Deps) *Server {
|
||||
if banlist == nil {
|
||||
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
|
||||
}
|
||||
blocklist := d.Blocklist
|
||||
if blocklist == nil {
|
||||
blocklist = ratelimit.NewBlocklist(false, nil)
|
||||
}
|
||||
rl := d.RateLimit
|
||||
if rl == (config.RateLimitConfig{}) {
|
||||
rl = config.DefaultRateLimit()
|
||||
}
|
||||
// Parse the minimum client version once. Config.validate already rejects an unparseable
|
||||
// value, so the warn branch only guards a direct (test) construction; an empty value leaves
|
||||
// the gate dormant.
|
||||
var minClient clientver.Version
|
||||
gateOn := false
|
||||
if d.MinClientVersion != "" {
|
||||
if v, ok := clientver.Parse(d.MinClientVersion); ok {
|
||||
minClient, gateOn = v, true
|
||||
} else {
|
||||
log.Warn("ignoring unparseable MinClientVersion; client-version gate disabled", zap.String("value", d.MinClientVersion))
|
||||
}
|
||||
}
|
||||
// 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,
|
||||
@@ -160,13 +226,18 @@ func NewServer(d Deps) *Server {
|
||||
limiter: limiter,
|
||||
tracker: tracker,
|
||||
banlist: banlist,
|
||||
blocklist: blocklist,
|
||||
honeytoken: d.Honeytoken,
|
||||
hub: d.Hub,
|
||||
heartbeat: d.Heartbeat,
|
||||
log: log,
|
||||
adminProxy: d.AdminProxy,
|
||||
metrics: newServerMetrics(d.Meter),
|
||||
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),
|
||||
@@ -255,6 +326,13 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
|
||||
http.Error(w, "banned", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
// The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work.
|
||||
// Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist.
|
||||
if s.blocklist.Blocked(ip) {
|
||||
s.metrics.recordBlocklistBlock(r.Context())
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.Header.Get(honeypotHeader) != "" {
|
||||
s.log.Warn("honeypot tripwire",
|
||||
zap.String("path", r.URL.Path),
|
||||
@@ -269,15 +347,71 @@ 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]) (*connect.Response[edgev1.ExecuteResponse], error) {
|
||||
func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.ExecuteRequest]) (resp *connect.Response[edgev1.ExecuteResponse], err 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"
|
||||
@@ -347,6 +481,11 @@ 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
|
||||
|
||||
@@ -22,8 +22,13 @@ import (
|
||||
)
|
||||
|
||||
// newEdge wires a connectsrv.Server over a fake backend and returns a Connect
|
||||
// client plus a cleanup func.
|
||||
// client plus a cleanup func. The client-version gate is off.
|
||||
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)
|
||||
@@ -31,12 +36,40 @@ func newEdge(t *testing.T, backendHandler http.HandlerFunc) (edgev1connect.Gatew
|
||||
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,
|
||||
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,
|
||||
})
|
||||
edgeSrv := httptest.NewServer(edge.HTTPHandler())
|
||||
client := edgev1connect.NewGatewayClient(http.DefaultClient, edgeSrv.URL)
|
||||
@@ -108,6 +141,132 @@ 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")
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against.
|
||||
type ipRange struct{ lo, hi uint32 }
|
||||
|
||||
// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced
|
||||
// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an
|
||||
// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is
|
||||
// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it).
|
||||
// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false.
|
||||
type Blocklist struct {
|
||||
enabled bool
|
||||
allow []ipRange // sorted, non-overlapping; from config, immutable after construction
|
||||
|
||||
mu sync.RWMutex
|
||||
ranges []ipRange // sorted, non-overlapping; the current feed
|
||||
fetchedAt time.Time // last successful SetCIDRs; zero = never loaded
|
||||
}
|
||||
|
||||
// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set
|
||||
// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it.
|
||||
func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist {
|
||||
return &Blocklist{enabled: enabled, allow: toRanges(allow)}
|
||||
}
|
||||
|
||||
// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is
|
||||
// false on a disabled or empty blocklist, and false for any non-IPv4 address.
|
||||
func (b *Blocklist) Blocked(ip string) bool {
|
||||
if !b.enabled {
|
||||
return false
|
||||
}
|
||||
v, ok := ipv4ToUint32(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
if len(b.ranges) == 0 || rangesContain(b.allow, v) {
|
||||
return false
|
||||
}
|
||||
return rangesContain(b.ranges, v)
|
||||
}
|
||||
|
||||
// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored.
|
||||
func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) {
|
||||
r := toRanges(cidrs)
|
||||
b.mu.Lock()
|
||||
b.ranges = r
|
||||
b.fetchedAt = at
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps
|
||||
// climbing. Blocked then returns false until a fresh feed loads.
|
||||
func (b *Blocklist) Clear() {
|
||||
b.mu.Lock()
|
||||
b.ranges = nil
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
// Len returns the number of ranges currently enforced.
|
||||
func (b *Blocklist) Len() int {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return len(b.ranges)
|
||||
}
|
||||
|
||||
// LastFetch returns the time of the last successful feed load (zero if never).
|
||||
func (b *Blocklist) LastFetch() time.Time {
|
||||
b.mu.RLock()
|
||||
defer b.mu.RUnlock()
|
||||
return b.fetchedAt
|
||||
}
|
||||
|
||||
// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics.
|
||||
type RefreshOutcome int
|
||||
|
||||
const (
|
||||
// RefreshUpdated: a new feed was fetched and applied.
|
||||
RefreshUpdated RefreshOutcome = iota
|
||||
// RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept.
|
||||
RefreshKept
|
||||
// RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open).
|
||||
RefreshDropped
|
||||
)
|
||||
|
||||
// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it
|
||||
// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open
|
||||
// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock.
|
||||
func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome {
|
||||
if fetchErr == nil {
|
||||
bl.SetCIDRs(cidrs, now)
|
||||
return RefreshUpdated
|
||||
}
|
||||
last := bl.LastFetch()
|
||||
if last.IsZero() || now.Sub(last) > maxStaleness {
|
||||
bl.Clear()
|
||||
return RefreshDropped
|
||||
}
|
||||
return RefreshKept
|
||||
}
|
||||
|
||||
// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail,
|
||||
// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and
|
||||
// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed.
|
||||
func ParseDROP(r io.Reader) ([]*net.IPNet, error) {
|
||||
var out []*net.IPNet
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if i := strings.IndexByte(line, ';'); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, "/") {
|
||||
line += "/32"
|
||||
}
|
||||
_, ipnet, err := net.ParseCIDR(line)
|
||||
if err != nil || ipnet.IP.To4() == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, ipnet)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, fmt.Errorf("ratelimit: read blocklist: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs
|
||||
// are dropped. Sorting by lo lets [rangesContain] binary-search.
|
||||
func toRanges(cidrs []*net.IPNet) []ipRange {
|
||||
out := make([]ipRange, 0, len(cidrs))
|
||||
for _, n := range cidrs {
|
||||
if n == nil {
|
||||
continue
|
||||
}
|
||||
ip4 := n.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
ones, bits := n.Mask.Size()
|
||||
if bits != 32 {
|
||||
continue
|
||||
}
|
||||
lo := binary.BigEndian.Uint32(ip4)
|
||||
hi := lo | (uint32(0xffffffff) >> uint(ones))
|
||||
out = append(out, ipRange{lo: lo, hi: hi})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo })
|
||||
return out
|
||||
}
|
||||
|
||||
// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a
|
||||
// binary search for the last range whose lo is at most v.
|
||||
func rangesContain(ranges []ipRange, v uint32) bool {
|
||||
i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v })
|
||||
return i > 0 && ranges[i-1].hi >= v
|
||||
}
|
||||
|
||||
// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4.
|
||||
func ipv4ToUint32(s string) (uint32, bool) {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
return 0, false
|
||||
}
|
||||
ip4 := ip.To4()
|
||||
if ip4 == nil {
|
||||
return 0, false
|
||||
}
|
||||
return binary.BigEndian.Uint32(ip4), true
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// cidrs parses CIDR strings into networks for a test fixture.
|
||||
func cidrs(t *testing.T, ss ...string) []*net.IPNet {
|
||||
t.Helper()
|
||||
out := make([]*net.IPNet, 0, len(ss))
|
||||
for _, s := range ss {
|
||||
_, n, err := net.ParseCIDR(s)
|
||||
if err != nil {
|
||||
t.Fatalf("bad cidr %q: %v", s, err)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestBlocklistBlocked(t *testing.T) {
|
||||
bl := NewBlocklist(true, cidrs(t, "10.20.30.0/24")) // allowlist
|
||||
bl.SetCIDRs(cidrs(t, "1.2.3.0/24", "203.0.113.5/32", "198.51.100.0/28", "10.20.30.0/24"), time.Now())
|
||||
cases := map[string]bool{
|
||||
"1.2.3.0": true, // range start
|
||||
"1.2.3.255": true, // range end
|
||||
"1.2.4.0": false, // just past the /24
|
||||
"203.0.113.5": true, // /32 exact
|
||||
"203.0.113.6": false,
|
||||
"198.51.100.15": true, // within /28
|
||||
"198.51.100.16": false, // past the /28
|
||||
"9.9.9.9": false, // not listed
|
||||
"10.20.30.99": false, // listed BUT allowlisted — never blocked
|
||||
"::1": false, // IPv6 — never matched here
|
||||
"not-an-ip": false,
|
||||
}
|
||||
for ip, want := range cases {
|
||||
if got := bl.Blocked(ip); got != want {
|
||||
t.Errorf("Blocked(%q) = %v, want %v", ip, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistDisabledOrEmpty(t *testing.T) {
|
||||
off := NewBlocklist(false, nil)
|
||||
off.SetCIDRs(cidrs(t, "1.2.3.0/24"), time.Now())
|
||||
if off.Blocked("1.2.3.4") {
|
||||
t.Error("a disabled blocklist must not block")
|
||||
}
|
||||
empty := NewBlocklist(true, nil)
|
||||
if empty.Blocked("1.2.3.4") {
|
||||
t.Error("an empty (never-loaded) blocklist must not block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDROP(t *testing.T) {
|
||||
in := "; Spamhaus DROP\n" +
|
||||
"1.2.3.0/24 ; SBL1\n" +
|
||||
" 198.51.100.0/28 ; SBL2\n" +
|
||||
"\n" +
|
||||
"203.0.113.5 ; a bare IP -> /32\n" +
|
||||
"2001:db8::/32 ; IPv6 skipped\n" +
|
||||
"garbage line\n"
|
||||
nets, err := ParseDROP(strings.NewReader(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(nets) != 3 {
|
||||
t.Fatalf("got %d networks, want 3: %v", len(nets), nets)
|
||||
}
|
||||
bl := NewBlocklist(true, nil)
|
||||
bl.SetCIDRs(nets, time.Now())
|
||||
for ip, want := range map[string]bool{"1.2.3.9": true, "198.51.100.1": true, "203.0.113.5": true, "203.0.113.6": false, "2001:db8::1": false} {
|
||||
if got := bl.Blocked(ip); got != want {
|
||||
t.Errorf("Blocked(%s) = %v, want %v", ip, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRefresh(t *testing.T) {
|
||||
bl := NewBlocklist(true, nil)
|
||||
now := time.Now()
|
||||
|
||||
if o := ApplyRefresh(bl, cidrs(t, "1.2.3.0/24"), nil, now, time.Hour); o != RefreshUpdated {
|
||||
t.Fatalf("success: want RefreshUpdated, got %v", o)
|
||||
}
|
||||
if !bl.Blocked("1.2.3.4") {
|
||||
t.Fatal("a successful refresh must apply the feed")
|
||||
}
|
||||
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(30*time.Minute), time.Hour); o != RefreshKept {
|
||||
t.Fatalf("fresh failure: want RefreshKept, got %v", o)
|
||||
}
|
||||
if !bl.Blocked("1.2.3.4") {
|
||||
t.Error("a kept feed must still block")
|
||||
}
|
||||
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(2*time.Hour), time.Hour); o != RefreshDropped {
|
||||
t.Fatalf("stale failure: want RefreshDropped, got %v", o)
|
||||
}
|
||||
if bl.Blocked("1.2.3.4") {
|
||||
t.Error("a stale feed must be dropped (fail-open)")
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,14 @@ const (
|
||||
)
|
||||
|
||||
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
|
||||
// Ack per Command.
|
||||
// Ack per Command and a periodic Health report.
|
||||
type FromBot struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Types that are valid to be assigned to Msg:
|
||||
//
|
||||
// *FromBot_Hello
|
||||
// *FromBot_Ack
|
||||
// *FromBot_Health
|
||||
Msg isFromBot_Msg `protobuf_oneof:"msg"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
@@ -97,6 +98,15 @@ func (x *FromBot) GetAck() *Ack {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *FromBot) GetHealth() *Health {
|
||||
if x != nil {
|
||||
if x, ok := x.Msg.(*FromBot_Health); ok {
|
||||
return x.Health
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type isFromBot_Msg interface {
|
||||
isFromBot_Msg()
|
||||
}
|
||||
@@ -109,10 +119,92 @@ type FromBot_Ack struct {
|
||||
Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
|
||||
}
|
||||
|
||||
type FromBot_Health struct {
|
||||
Health *Health `protobuf:"bytes,3,opt,name=health,proto3,oneof"`
|
||||
}
|
||||
|
||||
func (*FromBot_Hello) isFromBot_Msg() {}
|
||||
|
||||
func (*FromBot_Ack) isFromBot_Msg() {}
|
||||
|
||||
func (*FromBot_Health) isFromBot_Msg() {}
|
||||
|
||||
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
|
||||
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
|
||||
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
|
||||
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
|
||||
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
|
||||
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
|
||||
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
|
||||
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
|
||||
type Health struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ConnectFailures uint64 `protobuf:"varint,1,opt,name=connect_failures,json=connectFailures,proto3" json:"connect_failures,omitempty"` // getUpdates long-poll failures (transport / 5xx) since the last report
|
||||
ApiErrors uint64 `protobuf:"varint,2,opt,name=api_errors,json=apiErrors,proto3" json:"api_errors,omitempty"` // other Bot API failures (transport / 5xx) since the last report
|
||||
RateLimited uint64 `protobuf:"varint,3,opt,name=rate_limited,json=rateLimited,proto3" json:"rate_limited,omitempty"` // Bot API 429 responses since the last report (should stay ~0)
|
||||
LastOkUnix int64 `protobuf:"varint,4,opt,name=last_ok_unix,json=lastOkUnix,proto3" json:"last_ok_unix,omitempty"` // unix seconds of the last successful Bot API response (0 = none yet)
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Health) Reset() {
|
||||
*x = Health{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *Health) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*Health) ProtoMessage() {}
|
||||
|
||||
func (x *Health) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use Health.ProtoReflect.Descriptor instead.
|
||||
func (*Health) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *Health) GetConnectFailures() uint64 {
|
||||
if x != nil {
|
||||
return x.ConnectFailures
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Health) GetApiErrors() uint64 {
|
||||
if x != nil {
|
||||
return x.ApiErrors
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Health) GetRateLimited() uint64 {
|
||||
if x != nil {
|
||||
return x.RateLimited
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *Health) GetLastOkUnix() int64 {
|
||||
if x != nil {
|
||||
return x.LastOkUnix
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
|
||||
type ToBot struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -123,7 +215,7 @@ type ToBot struct {
|
||||
|
||||
func (x *ToBot) Reset() {
|
||||
*x = ToBot{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -135,7 +227,7 @@ func (x *ToBot) String() string {
|
||||
func (*ToBot) ProtoMessage() {}
|
||||
|
||||
func (x *ToBot) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -148,7 +240,7 @@ func (x *ToBot) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ToBot.ProtoReflect.Descriptor instead.
|
||||
func (*ToBot) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *ToBot) GetCommand() *Command {
|
||||
@@ -171,7 +263,7 @@ type Hello struct {
|
||||
|
||||
func (x *Hello) Reset() {
|
||||
*x = Hello{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -183,7 +275,7 @@ func (x *Hello) String() string {
|
||||
func (*Hello) ProtoMessage() {}
|
||||
|
||||
func (x *Hello) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -196,7 +288,7 @@ func (x *Hello) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
|
||||
func (*Hello) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *Hello) GetInstanceId() string {
|
||||
@@ -233,7 +325,7 @@ type Command struct {
|
||||
|
||||
func (x *Command) Reset() {
|
||||
*x = Command{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -245,7 +337,7 @@ func (x *Command) String() string {
|
||||
func (*Command) ProtoMessage() {}
|
||||
|
||||
func (x *Command) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -258,7 +350,7 @@ func (x *Command) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Command.ProtoReflect.Descriptor instead.
|
||||
func (*Command) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *Command) GetCommandId() string {
|
||||
@@ -372,7 +464,7 @@ type Ack struct {
|
||||
|
||||
func (x *Ack) Reset() {
|
||||
*x = Ack{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -384,7 +476,7 @@ func (x *Ack) String() string {
|
||||
func (*Ack) ProtoMessage() {}
|
||||
|
||||
func (x *Ack) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -397,7 +489,7 @@ func (x *Ack) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
|
||||
func (*Ack) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *Ack) GetCommandId() string {
|
||||
@@ -445,7 +537,7 @@ type ChatGateCommand struct {
|
||||
|
||||
func (x *ChatGateCommand) Reset() {
|
||||
*x = ChatGateCommand{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -457,7 +549,7 @@ func (x *ChatGateCommand) String() string {
|
||||
func (*ChatGateCommand) ProtoMessage() {}
|
||||
|
||||
func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -470,7 +562,7 @@ func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead.
|
||||
func (*ChatGateCommand) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *ChatGateCommand) GetExternalId() string {
|
||||
@@ -498,7 +590,7 @@ type ChatEligibilityRequest struct {
|
||||
|
||||
func (x *ChatEligibilityRequest) Reset() {
|
||||
*x = ChatEligibilityRequest{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -510,7 +602,7 @@ func (x *ChatEligibilityRequest) String() string {
|
||||
func (*ChatEligibilityRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -523,7 +615,7 @@ func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *ChatEligibilityRequest) GetExternalId() string {
|
||||
@@ -546,7 +638,7 @@ type ChatEligibilityResponse struct {
|
||||
|
||||
func (x *ChatEligibilityResponse) Reset() {
|
||||
*x = ChatEligibilityResponse{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -558,7 +650,7 @@ func (x *ChatEligibilityResponse) String() string {
|
||||
func (*ChatEligibilityResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -571,7 +663,7 @@ func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *ChatEligibilityResponse) GetRegistered() bool {
|
||||
@@ -605,7 +697,7 @@ type CreateInvoiceCommand struct {
|
||||
|
||||
func (x *CreateInvoiceCommand) Reset() {
|
||||
*x = CreateInvoiceCommand{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -617,7 +709,7 @@ func (x *CreateInvoiceCommand) String() string {
|
||||
func (*CreateInvoiceCommand) ProtoMessage() {}
|
||||
|
||||
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -630,7 +722,7 @@ func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
|
||||
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *CreateInvoiceCommand) GetTitle() string {
|
||||
@@ -674,7 +766,7 @@ type PreCheckoutRequest struct {
|
||||
|
||||
func (x *PreCheckoutRequest) Reset() {
|
||||
*x = PreCheckoutRequest{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -686,7 +778,7 @@ func (x *PreCheckoutRequest) String() string {
|
||||
func (*PreCheckoutRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -699,7 +791,7 @@ func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
|
||||
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *PreCheckoutRequest) GetOrderId() string {
|
||||
@@ -735,7 +827,7 @@ type PreCheckoutResponse struct {
|
||||
|
||||
func (x *PreCheckoutResponse) Reset() {
|
||||
*x = PreCheckoutResponse{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -747,7 +839,7 @@ func (x *PreCheckoutResponse) String() string {
|
||||
func (*PreCheckoutResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -760,7 +852,7 @@ func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
|
||||
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *PreCheckoutResponse) GetOk() bool {
|
||||
@@ -792,7 +884,7 @@ type ForwardPaymentRequest struct {
|
||||
|
||||
func (x *ForwardPaymentRequest) Reset() {
|
||||
*x = ForwardPaymentRequest{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -804,7 +896,7 @@ func (x *ForwardPaymentRequest) String() string {
|
||||
func (*ForwardPaymentRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -817,7 +909,7 @@ func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentRequest) GetOrderId() string {
|
||||
@@ -861,7 +953,7 @@ type ForwardPaymentResponse struct {
|
||||
|
||||
func (x *ForwardPaymentResponse) Reset() {
|
||||
*x = ForwardPaymentResponse{}
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -873,7 +965,7 @@ func (x *ForwardPaymentResponse) String() string {
|
||||
func (*ForwardPaymentResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
|
||||
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -886,7 +978,7 @@ func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
|
||||
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *ForwardPaymentResponse) GetCredited() bool {
|
||||
@@ -900,11 +992,19 @@ var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"r\n" +
|
||||
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"\xa9\x01\n" +
|
||||
"\aFromBot\x122\n" +
|
||||
"\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" +
|
||||
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ackB\x05\n" +
|
||||
"\x03msg\"?\n" +
|
||||
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ack\x125\n" +
|
||||
"\x06health\x18\x03 \x01(\v2\x1b.scrabble.botlink.v1.HealthH\x00R\x06healthB\x05\n" +
|
||||
"\x03msg\"\x97\x01\n" +
|
||||
"\x06Health\x12)\n" +
|
||||
"\x10connect_failures\x18\x01 \x01(\x04R\x0fconnectFailures\x12\x1d\n" +
|
||||
"\n" +
|
||||
"api_errors\x18\x02 \x01(\x04R\tapiErrors\x12!\n" +
|
||||
"\frate_limited\x18\x03 \x01(\x04R\vrateLimited\x12 \n" +
|
||||
"\flast_ok_unix\x18\x04 \x01(\x03R\n" +
|
||||
"lastOkUnix\"?\n" +
|
||||
"\x05ToBot\x126\n" +
|
||||
"\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" +
|
||||
"\x05Hello\x12\x1f\n" +
|
||||
@@ -976,47 +1076,49 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
|
||||
return file_botlink_v1_botlink_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
|
||||
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
|
||||
var file_botlink_v1_botlink_proto_goTypes = []any{
|
||||
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
|
||||
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
|
||||
(*Hello)(nil), // 2: scrabble.botlink.v1.Hello
|
||||
(*Command)(nil), // 3: scrabble.botlink.v1.Command
|
||||
(*Ack)(nil), // 4: scrabble.botlink.v1.Ack
|
||||
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
|
||||
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
|
||||
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
|
||||
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
|
||||
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
|
||||
(*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse
|
||||
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
|
||||
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
|
||||
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
|
||||
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
|
||||
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
|
||||
(*Health)(nil), // 1: scrabble.botlink.v1.Health
|
||||
(*ToBot)(nil), // 2: scrabble.botlink.v1.ToBot
|
||||
(*Hello)(nil), // 3: scrabble.botlink.v1.Hello
|
||||
(*Command)(nil), // 4: scrabble.botlink.v1.Command
|
||||
(*Ack)(nil), // 5: scrabble.botlink.v1.Ack
|
||||
(*ChatGateCommand)(nil), // 6: scrabble.botlink.v1.ChatGateCommand
|
||||
(*ChatEligibilityRequest)(nil), // 7: scrabble.botlink.v1.ChatEligibilityRequest
|
||||
(*ChatEligibilityResponse)(nil), // 8: scrabble.botlink.v1.ChatEligibilityResponse
|
||||
(*CreateInvoiceCommand)(nil), // 9: scrabble.botlink.v1.CreateInvoiceCommand
|
||||
(*PreCheckoutRequest)(nil), // 10: scrabble.botlink.v1.PreCheckoutRequest
|
||||
(*PreCheckoutResponse)(nil), // 11: scrabble.botlink.v1.PreCheckoutResponse
|
||||
(*ForwardPaymentRequest)(nil), // 12: scrabble.botlink.v1.ForwardPaymentRequest
|
||||
(*ForwardPaymentResponse)(nil), // 13: scrabble.botlink.v1.ForwardPaymentResponse
|
||||
(*v1.NotifyRequest)(nil), // 14: scrabble.telegram.v1.NotifyRequest
|
||||
(*v1.SendToUserRequest)(nil), // 15: scrabble.telegram.v1.SendToUserRequest
|
||||
(*v1.SendToGameChannelRequest)(nil), // 16: scrabble.telegram.v1.SendToGameChannelRequest
|
||||
}
|
||||
var file_botlink_v1_botlink_proto_depIdxs = []int32{
|
||||
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
|
||||
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
|
||||
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
|
||||
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
|
||||
14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
|
||||
15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
|
||||
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
|
||||
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
|
||||
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
|
||||
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
|
||||
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
|
||||
12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
|
||||
12, // [12:16] is the sub-list for method output_type
|
||||
8, // [8:12] is the sub-list for method input_type
|
||||
8, // [8:8] is the sub-list for extension type_name
|
||||
8, // [8:8] is the sub-list for extension extendee
|
||||
0, // [0:8] is the sub-list for field type_name
|
||||
3, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
|
||||
5, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
|
||||
1, // 2: scrabble.botlink.v1.FromBot.health:type_name -> scrabble.botlink.v1.Health
|
||||
4, // 3: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
|
||||
14, // 4: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
|
||||
15, // 5: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
|
||||
16, // 6: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
|
||||
6, // 7: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
|
||||
9, // 8: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
|
||||
0, // 9: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||
10, // 11: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
|
||||
12, // 12: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
|
||||
2, // 13: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||
8, // 14: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||
11, // 15: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
|
||||
13, // 16: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
|
||||
13, // [13:17] is the sub-list for method output_type
|
||||
9, // [9:13] is the sub-list for method input_type
|
||||
9, // [9:9] is the sub-list for extension type_name
|
||||
9, // [9:9] is the sub-list for extension extendee
|
||||
0, // [0:9] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_botlink_v1_botlink_proto_init() }
|
||||
@@ -1027,8 +1129,9 @@ func file_botlink_v1_botlink_proto_init() {
|
||||
file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{
|
||||
(*FromBot_Hello)(nil),
|
||||
(*FromBot_Ack)(nil),
|
||||
(*FromBot_Health)(nil),
|
||||
}
|
||||
file_botlink_v1_botlink_proto_msgTypes[3].OneofWrappers = []any{
|
||||
file_botlink_v1_botlink_proto_msgTypes[4].OneofWrappers = []any{
|
||||
(*Command_Notify)(nil),
|
||||
(*Command_SendToUser)(nil),
|
||||
(*Command_SendToChannel)(nil),
|
||||
@@ -1041,7 +1144,7 @@ func file_botlink_v1_botlink_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 13,
|
||||
NumMessages: 14,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -48,14 +48,30 @@ service BotLink {
|
||||
}
|
||||
|
||||
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
|
||||
// Ack per Command.
|
||||
// Ack per Command and a periodic Health report.
|
||||
message FromBot {
|
||||
oneof msg {
|
||||
Hello hello = 1;
|
||||
Ack ack = 2;
|
||||
Health health = 3;
|
||||
}
|
||||
}
|
||||
|
||||
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
|
||||
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
|
||||
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
|
||||
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
|
||||
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
|
||||
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
|
||||
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
|
||||
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
|
||||
message Health {
|
||||
uint64 connect_failures = 1; // getUpdates long-poll failures (transport / 5xx) since the last report
|
||||
uint64 api_errors = 2; // other Bot API failures (transport / 5xx) since the last report
|
||||
uint64 rate_limited = 3; // Bot API 429 responses since the last report (should stay ~0)
|
||||
int64 last_ok_unix = 4; // unix seconds of the last successful Bot API response (0 = none yet)
|
||||
}
|
||||
|
||||
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
|
||||
message ToBot {
|
||||
Command command = 1;
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/botlink"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/health"
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/promobot"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
@@ -31,6 +32,10 @@ import (
|
||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||
const telemetryShutdownTimeout = 5 * time.Second
|
||||
|
||||
// healthReportInterval is how often the bot flushes its accumulated Bot API health to the gateway
|
||||
// over the bot-link. It bounds how stale a metric can be, not how often the bot talks to Telegram.
|
||||
const healthReportInterval = 30 * time.Second
|
||||
|
||||
func main() {
|
||||
cfg, err := config.LoadBot()
|
||||
if err != nil {
|
||||
@@ -81,6 +86,12 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
}
|
||||
}
|
||||
|
||||
// The bot exports no telemetry of its own (otelcol is on the main host, unreachable from here),
|
||||
// so its Bot API health is observed centrally and reported to the gateway over the bot-link,
|
||||
// which turns the reports into metrics. Shared between the bot (which feeds it) and the bot-link
|
||||
// client (which flushes it).
|
||||
healthReporter := health.NewReporter()
|
||||
|
||||
b, err := bot.New(bot.Config{
|
||||
Token: cfg.Token,
|
||||
APIBaseURL: cfg.APIBaseURL,
|
||||
@@ -92,6 +103,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
SupportChatID: cfg.SupportChatID,
|
||||
SupportStore: supportStore,
|
||||
AcceptPayments: cfg.StarsOutboxDir != "",
|
||||
Health: healthReporter,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -120,6 +132,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
OwnsUpdates: cfg.OwnsUpdates,
|
||||
Creds: credentials.NewTLS(tlsCfg),
|
||||
ReconnectDelay: cfg.BotLink.ReconnectDelay,
|
||||
Health: healthReporter,
|
||||
HealthInterval: healthReportInterval,
|
||||
}, exec, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -7,19 +7,26 @@ package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"scrabble/platform/telegram/internal/health"
|
||||
"scrabble/platform/telegram/internal/outbox"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
// botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set
|
||||
// it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout.
|
||||
const botPollTimeout = time.Minute
|
||||
|
||||
// Config configures the bot wrapper.
|
||||
type Config struct {
|
||||
// Token is the Bot API token.
|
||||
@@ -54,6 +61,9 @@ type Config struct {
|
||||
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
|
||||
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
|
||||
AcceptPayments bool
|
||||
// Health, when set, observes every Bot API request for health metrics (reported to the gateway
|
||||
// over the bot-link) and honours a 429's Retry-After. Nil disables the observation.
|
||||
Health *health.Reporter
|
||||
}
|
||||
|
||||
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||
@@ -159,6 +169,12 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if cfg.APIBaseURL != "" {
|
||||
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
|
||||
}
|
||||
if cfg.Health != nil {
|
||||
// Observe every Bot API request centrally for health metrics and the 429 back-off. The
|
||||
// client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side).
|
||||
base := &http.Client{Timeout: botPollTimeout + 10*time.Second}
|
||||
opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base)))
|
||||
}
|
||||
api, err := tgbot.New(cfg.Token, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"google.golang.org/grpc/keepalive"
|
||||
|
||||
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
||||
"scrabble/platform/telegram/internal/health"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,6 +35,11 @@ type ClientConfig struct {
|
||||
Creds credentials.TransportCredentials
|
||||
// ReconnectDelay is the pause before re-dialing after the stream ends.
|
||||
ReconnectDelay time.Duration
|
||||
// Health, when set with a positive HealthInterval, is flushed to the gateway as a botlink Health
|
||||
// message every HealthInterval while the stream is open. Nil disables health reporting.
|
||||
Health *health.Reporter
|
||||
// HealthInterval is the cadence of the Health flush; 0 disables it.
|
||||
HealthInterval time.Duration
|
||||
}
|
||||
|
||||
// Client maintains the long-lived bot-link to the gateway, executing the commands
|
||||
@@ -140,22 +146,59 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
|
||||
}
|
||||
c.log.Info("bot-link connected", zap.String("gateway", c.cfg.GatewayAddr), zap.Bool("owns_updates", c.cfg.OwnsUpdates))
|
||||
|
||||
// One goroutine owns stream.Recv, feeding commands to the loop below; the loop owns every
|
||||
// stream.Send (the Acks and the periodic Health) — a gRPC stream forbids concurrent Send.
|
||||
rctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
cmds := make(chan *botlinkv1.Command)
|
||||
recvErr := make(chan error, 1)
|
||||
go func() {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
recvErr <- err
|
||||
return
|
||||
}
|
||||
if cmd := msg.GetCommand(); cmd != nil {
|
||||
select {
|
||||
case cmds <- cmd:
|
||||
case <-rctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var healthTick <-chan time.Time
|
||||
if c.cfg.Health != nil && c.cfg.HealthInterval > 0 {
|
||||
t := time.NewTicker(c.cfg.HealthInterval)
|
||||
defer t.Stop()
|
||||
healthTick = t.C
|
||||
}
|
||||
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := msg.GetCommand()
|
||||
if cmd == nil {
|
||||
continue
|
||||
}
|
||||
delivered, result, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
|
||||
if herr != nil {
|
||||
ack.Error = herr.Error()
|
||||
}
|
||||
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-recvErr:
|
||||
return err
|
||||
case cmd := <-cmds:
|
||||
delivered, result, herr := c.exec.Handle(ctx, cmd)
|
||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
|
||||
if herr != nil {
|
||||
ack.Error = herr.Error()
|
||||
}
|
||||
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-healthTick:
|
||||
// Snapshot without resetting; only commit (clear the deltas) after a successful send, so a
|
||||
// failed flush loses nothing and the counts ride the next connection.
|
||||
hb := c.cfg.Health.Snapshot()
|
||||
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Health{Health: hb}}); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cfg.Health.Commit(hb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package health tracks the Telegram bot's Bot API health and reports it to the gateway over the
|
||||
// bot-link. The bot exports no telemetry of its own — otelcol lives on the main host, unreachable
|
||||
// from the bot host — so the gateway turns these reports into its own metrics (see
|
||||
// gateway/internal/botlink). Health is observed CENTRALLY by wrapping the Bot API HTTP client
|
||||
// ([Reporter.Wrap]); every request is classified there with no per-call-site instrumentation.
|
||||
package health
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
||||
)
|
||||
|
||||
// maxBackoff caps how long a single 429 makes the bot wait, so a hostile or buggy Retry-After can
|
||||
// never wedge the bot.
|
||||
const maxBackoff = 30 * time.Second
|
||||
|
||||
// Reporter accumulates the bot's Bot API health between reports: three delta counters and the last
|
||||
// successful-response stamp. It is safe for concurrent use. The bot-link client periodically
|
||||
// [Reporter.Snapshot]s it, sends the snapshot as a botlink Health, and [Reporter.Commit]s it on a
|
||||
// successful send.
|
||||
type Reporter struct {
|
||||
connectFailures atomic.Int64
|
||||
apiErrors atomic.Int64
|
||||
rateLimited atomic.Int64
|
||||
lastOKUnix atomic.Int64
|
||||
}
|
||||
|
||||
// NewReporter returns an empty Reporter.
|
||||
func NewReporter() *Reporter { return &Reporter{} }
|
||||
|
||||
// Snapshot reads the current delta counters and the last-ok stamp into a Health message WITHOUT
|
||||
// resetting them. The counters are cleared only by [Reporter.Commit] after a successful send, so a
|
||||
// failed send loses nothing.
|
||||
func (r *Reporter) Snapshot() *botlinkv1.Health {
|
||||
return &botlinkv1.Health{
|
||||
ConnectFailures: uint64(max(r.connectFailures.Load(), 0)),
|
||||
ApiErrors: uint64(max(r.apiErrors.Load(), 0)),
|
||||
RateLimited: uint64(max(r.rateLimited.Load(), 0)),
|
||||
LastOkUnix: r.lastOKUnix.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Commit subtracts a just-sent snapshot's delta counters, so counts accrued between the snapshot and
|
||||
// the send survive into the next report. last_ok is a stamp, not a delta, so it is left as is.
|
||||
func (r *Reporter) Commit(sent *botlinkv1.Health) {
|
||||
r.connectFailures.Add(-int64(sent.GetConnectFailures()))
|
||||
r.apiErrors.Add(-int64(sent.GetApiErrors()))
|
||||
r.rateLimited.Add(-int64(sent.GetRateLimited()))
|
||||
}
|
||||
|
||||
// Wrap returns an http.Client-shaped observer over base for tgbot.WithHTTPClient: it stamps liveness
|
||||
// on every successful response, counts transport and 5xx failures (split getUpdates vs other) and
|
||||
// 429s, and honours a 429's Retry-After (bounded by maxBackoff) so the bot backs off instead of
|
||||
// hammering. A 4xx other than 429 (a user blocked the bot, a malformed request) is a normal
|
||||
// per-request outcome, not a health signal, and is not counted.
|
||||
func (r *Reporter) Wrap(base Doer) Doer { return &observer{base: base, r: r} }
|
||||
|
||||
// Doer is the minimal HTTP surface the Bot API client needs; both *http.Client and [observer]
|
||||
// satisfy it, and it matches the go-telegram/bot HttpClient interface.
|
||||
type Doer interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
// observer is the Bot API HTTP client wrapper (see [Reporter.Wrap]).
|
||||
type observer struct {
|
||||
base Doer
|
||||
r *Reporter
|
||||
}
|
||||
|
||||
// Do performs the request and records its health outcome.
|
||||
func (o *observer) Do(req *http.Request) (*http.Response, error) {
|
||||
resp, err := o.base.Do(req)
|
||||
poll := strings.HasSuffix(req.URL.Path, "/getUpdates")
|
||||
if err != nil {
|
||||
// A cancelled context is a shutdown, not a Bot API failure.
|
||||
if req.Context().Err() == nil {
|
||||
o.fail(poll)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
o.r.rateLimited.Add(1)
|
||||
o.backoff(req.Context(), resp)
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
o.r.lastOKUnix.Store(time.Now().Unix())
|
||||
case resp.StatusCode >= 500:
|
||||
o.fail(poll)
|
||||
}
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// fail records a connect failure on the getUpdates long-poll or an api error otherwise.
|
||||
func (o *observer) fail(poll bool) {
|
||||
if poll {
|
||||
o.r.connectFailures.Add(1)
|
||||
} else {
|
||||
o.r.apiErrors.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// backoff waits out a 429's Retry-After (bounded, cancellable). It buffers the response body first so
|
||||
// the wait holds no connection open and the Bot API library can still parse the 429 afterwards.
|
||||
func (o *observer) backoff(ctx context.Context, resp *http.Response) {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
d := retryAfter(resp.Header.Get("Retry-After"), body)
|
||||
if d <= 0 {
|
||||
return
|
||||
}
|
||||
if d > maxBackoff {
|
||||
d = maxBackoff
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case <-time.After(d):
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfter resolves the 429 back-off from the Retry-After header, falling back to the Bot API
|
||||
// body's parameters.retry_after (both are seconds). It returns 0 when neither gives a positive value.
|
||||
func retryAfter(header string, body []byte) time.Duration {
|
||||
if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs > 0 {
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
var payload struct {
|
||||
Parameters struct {
|
||||
RetryAfter int `json:"retry_after"`
|
||||
} `json:"parameters"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err == nil && payload.Parameters.RetryAfter > 0 {
|
||||
return time.Duration(payload.Parameters.RetryAfter) * time.Second
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeDoer returns a canned response and error for every request.
|
||||
type fakeDoer struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDoer) Do(*http.Request) (*http.Response, error) { return f.resp, f.err }
|
||||
|
||||
// mkResp builds a response with a status, optional Retry-After header and body.
|
||||
func mkResp(status int, retryAfter, body string) *http.Response {
|
||||
h := http.Header{}
|
||||
if retryAfter != "" {
|
||||
h.Set("Retry-After", retryAfter)
|
||||
}
|
||||
return &http.Response{StatusCode: status, Header: h, Body: io.NopCloser(strings.NewReader(body))}
|
||||
}
|
||||
|
||||
// run sends one request for the Bot API method through a fresh observer and returns the reporter's
|
||||
// resulting snapshot. The 429 cases carry no Retry-After, so the back-off returns before any wait.
|
||||
func run(method string, resp *http.Response, err error) *Reporter {
|
||||
r := NewReporter()
|
||||
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/"+method, nil)
|
||||
_, _ = r.Wrap(&fakeDoer{resp: resp, err: err}).Do(req)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestObserverClassifies(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
resp *http.Response
|
||||
err error
|
||||
wantConnect uint64
|
||||
wantAPI uint64
|
||||
wantRate uint64
|
||||
wantLastOKSet bool
|
||||
}{
|
||||
{"poll ok stamps liveness", "getUpdates", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
|
||||
{"send ok stamps liveness", "sendMessage", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
|
||||
{"poll 5xx is a connect failure", "getUpdates", mkResp(502, "", ""), nil, 1, 0, 0, false},
|
||||
{"send 5xx is an api error", "sendMessage", mkResp(500, "", ""), nil, 0, 1, 0, false},
|
||||
{"429 is rate limited, not an error", "sendMessage", mkResp(429, "", `{"ok":false}`), nil, 0, 0, 1, false},
|
||||
{"4xx other than 429 is not counted", "sendMessage", mkResp(403, "", ""), nil, 0, 0, 0, false},
|
||||
{"poll transport error is a connect failure", "getUpdates", nil, errors.New("dial"), 1, 0, 0, false},
|
||||
{"send transport error is an api error", "sendMessage", nil, errors.New("dial"), 0, 1, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := run(tc.method, tc.resp, tc.err).Snapshot()
|
||||
if s.GetConnectFailures() != tc.wantConnect || s.GetApiErrors() != tc.wantAPI || s.GetRateLimited() != tc.wantRate {
|
||||
t.Errorf("counters = connect %d api %d rate %d, want %d/%d/%d",
|
||||
s.GetConnectFailures(), s.GetApiErrors(), s.GetRateLimited(), tc.wantConnect, tc.wantAPI, tc.wantRate)
|
||||
}
|
||||
if (s.GetLastOkUnix() > 0) != tc.wantLastOKSet {
|
||||
t.Errorf("last_ok set = %v, want %v", s.GetLastOkUnix() > 0, tc.wantLastOKSet)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestObserverIgnoresShutdownTransportError checks a transport error under a cancelled context (a
|
||||
// shutdown) is not counted as a Bot API failure.
|
||||
func TestObserverIgnoresShutdownTransportError(t *testing.T) {
|
||||
r := NewReporter()
|
||||
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/getUpdates", nil)
|
||||
ctx, cancel := context.WithCancel(req.Context())
|
||||
cancel()
|
||||
_, _ = r.Wrap(&fakeDoer{err: errors.New("dial")}).Do(req.WithContext(ctx))
|
||||
if s := r.Snapshot(); s.GetConnectFailures() != 0 {
|
||||
t.Errorf("shutdown transport error must not count, got connect=%d", s.GetConnectFailures())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryAfter(t *testing.T) {
|
||||
cases := []struct {
|
||||
header string
|
||||
body string
|
||||
want time.Duration
|
||||
}{
|
||||
{"5", "", 5 * time.Second},
|
||||
{"", `{"parameters":{"retry_after":7}}`, 7 * time.Second},
|
||||
{"3", `{"parameters":{"retry_after":9}}`, 3 * time.Second}, // header wins
|
||||
{"", `{"ok":false}`, 0},
|
||||
{"0", "", 0},
|
||||
{"", "not json", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := retryAfter(tc.header, []byte(tc.body)); got != tc.want {
|
||||
t.Errorf("retryAfter(%q, %q) = %v, want %v", tc.header, tc.body, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCommit checks Commit clears only the sent deltas, so counts accrued between the
|
||||
// snapshot and the send survive into the next report.
|
||||
func TestSnapshotCommit(t *testing.T) {
|
||||
r := NewReporter()
|
||||
r.apiErrors.Add(3)
|
||||
snap := r.Snapshot()
|
||||
if snap.GetApiErrors() != 3 {
|
||||
t.Fatalf("snapshot api_errors = %d, want 3", snap.GetApiErrors())
|
||||
}
|
||||
r.apiErrors.Add(2) // accrues after the snapshot
|
||||
r.Commit(snap)
|
||||
if got := r.Snapshot().GetApiErrors(); got != 2 {
|
||||
t.Errorf("after commit api_errors = %d, want 2 (the post-snapshot accrual)", got)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +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 ./src/
|
||||
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"]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# renderer — the finished-game image-render sidecar
|
||||
# renderer — the render sidecar (finished-game image + public offer page)
|
||||
|
||||
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
|
||||
**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image
|
||||
build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on
|
||||
[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is
|
||||
pixel-identical to the design the owner signed off in the browser.
|
||||
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
|
||||
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
|
||||
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
|
||||
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
|
||||
signed off) and the public **offer page**.
|
||||
|
||||
## Interface
|
||||
|
||||
@@ -12,20 +12,30 @@ pixel-identical to the design the owner signed off in the browser.
|
||||
`image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`);
|
||||
`alphabet` is the per-variant `(index, letter, value)` table tile values are drawn
|
||||
from; `labels` localizes the non-play moves (pass/exchange/resign/timeout);
|
||||
`hostname` + `dateLocale` feed the footer.
|
||||
`hostname` + `dateLocale` feed the footer. Internal-only.
|
||||
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
|
||||
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
|
||||
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
|
||||
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
|
||||
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
|
||||
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
|
||||
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
|
||||
|
||||
The service draws and nothing else: authentication, the participant check and the signed
|
||||
public download URL all live in the backend (`backend/internal/server/export.go`); the
|
||||
network path is client → gateway `/dl/*` → backend → this sidecar.
|
||||
The service renders and nothing else: for the PNG, authentication, the participant check and the
|
||||
signed public download URL all live in the backend (`backend/internal/server/export.go`), the path
|
||||
being client → gateway `/dl/*` → backend → this sidecar; for the offer, the catalog projection and
|
||||
its cache live in the backend (`backend/internal/payments/offer.go`) and no user input reaches the
|
||||
page.
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
|
||||
# (allowBuilds) or the native binary is silently never fetched
|
||||
pnpm test # bundles, then node --test against testdata/request.json
|
||||
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
|
||||
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
|
||||
|
||||
@@ -9,5 +9,9 @@ await build({
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
outfile: 'dist/gameimage.mjs',
|
||||
// The shared ui/src/lib modules are copied without their node_modules, so a bare npm import they
|
||||
// make (offer.ts → 'marked', the offer renderer's markdown parser) is not resolvable from the ui
|
||||
// tree. NODE_PATH-style fallback to the renderer's own node_modules, where marked is a dependency.
|
||||
nodePaths: ['node_modules'],
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"test": "pnpm run bundle && node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.5",
|
||||
"skia-canvas": "^3.0.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
marked:
|
||||
specifier: ^18.0.5
|
||||
version: 18.0.6
|
||||
skia-canvas:
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.8
|
||||
@@ -209,6 +212,11 @@ packages:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
marked@18.0.6:
|
||||
resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -347,6 +355,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
marked@18.0.6: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
parenthesis@3.1.8: {}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
|
||||
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
|
||||
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
|
||||
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
|
||||
// The esbuild bundle entry: re-exports the SHARED ui/src/lib modules — the exact modules the
|
||||
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
|
||||
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
|
||||
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
|
||||
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
|
||||
// used to invoke, now server-side so the live catalog price list can be spliced in.
|
||||
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
|
||||
export { setAlphabet } from '../../ui/src/lib/alphabet';
|
||||
export { renderOfferHtml } from '../../ui/src/lib/offer';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer
|
||||
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
|
||||
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
|
||||
import { renderOfferHtml } from '../dist/gameimage.mjs';
|
||||
|
||||
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It
|
||||
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
|
||||
// tables before rendering.
|
||||
export const pricingMarker = '<#pricing_template#>';
|
||||
|
||||
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker
|
||||
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
|
||||
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
|
||||
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
|
||||
export function renderOffer(offerMarkdown, pricingTables) {
|
||||
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
|
||||
}
|
||||
@@ -1,20 +1,43 @@
|
||||
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
|
||||
// finished-game export image. It runs the same drawGameImage the browser build ships
|
||||
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
|
||||
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
|
||||
// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib
|
||||
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
|
||||
//
|
||||
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
|
||||
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
|
||||
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here)
|
||||
// GET /offer → 301 /offer/
|
||||
// GET /healthz → 200
|
||||
//
|
||||
// The service is internal-only (docker network `internal`); authentication, participant
|
||||
// checks and the signed public URL all live in the backend — this process only draws.
|
||||
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
|
||||
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
|
||||
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
|
||||
// markdown; no user input reaches it.
|
||||
import { createServer } from 'node:http';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { renderRequest } from './render.mjs';
|
||||
import { renderOffer } from './offer.mjs';
|
||||
|
||||
const PORT = Number(process.env.RENDERER_PORT || 8090);
|
||||
// A render request is a finished game's journal — generously capped.
|
||||
const MAX_BODY = 2 * 1024 * 1024;
|
||||
|
||||
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the
|
||||
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge
|
||||
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked
|
||||
// path for a local run (point it at ../ui/legal/offer_ru.md).
|
||||
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8');
|
||||
const OFFER_PRICING_URL =
|
||||
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
|
||||
|
||||
// fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a
|
||||
// 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list.
|
||||
async function fetchOfferPricing() {
|
||||
const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) });
|
||||
if (!resp.ok) {
|
||||
throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 });
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
@@ -35,11 +58,23 @@ function readBody(req) {
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method === 'GET' && req.url === '/healthz') {
|
||||
const path = (req.url || '').split('?')[0];
|
||||
if (req.method === 'GET' && path === '/healthz') {
|
||||
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/render') {
|
||||
if (req.method === 'GET' && path === '/offer') {
|
||||
res.writeHead(301, { location: '/offer/' }).end();
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && path === '/offer/') {
|
||||
const html = renderOffer(OFFER_MD, await fetchOfferPricing());
|
||||
res
|
||||
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
|
||||
.end(html);
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && path === '/render') {
|
||||
const png = await renderRequest(JSON.parse(await readBody(req)));
|
||||
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list
|
||||
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml
|
||||
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts);
|
||||
// here we assert the substitution and that the tables reach the rendered HTML.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { renderOffer, pricingMarker } from '../src/offer.mjs';
|
||||
|
||||
const tables =
|
||||
'| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n' +
|
||||
'| --- | --- | --- | --- |\n' +
|
||||
'| 50 «Фишек» | 200.00 | 30 | 100 |';
|
||||
|
||||
test('splices the price list into the offer and renders the tables to HTML', () => {
|
||||
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
|
||||
const html = renderOffer(md, tables);
|
||||
// The marker is gone and the projected table reached the document as a real HTML table.
|
||||
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted');
|
||||
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
|
||||
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
|
||||
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
|
||||
// The offer chrome from the shared renderer is intact.
|
||||
assert.ok(html.includes('<!doctype html>'));
|
||||
});
|
||||
|
||||
test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
|
||||
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |');
|
||||
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
|
||||
});
|
||||
@@ -31,6 +31,11 @@ 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 `/`).
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# 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
|
||||
@@ -0,0 +1,2 @@
|
||||
/build/*
|
||||
!/build/.npmkeep
|
||||
@@ -0,0 +1,81 @@
|
||||
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")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
# 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
|
||||
@@ -0,0 +1,26 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?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>
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.eruditgame.app;
|
||||
|
||||
import com.getcapacitor.BridgeActivity;
|
||||
|
||||
public class MainActivity extends BridgeActivity {}
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,34 @@
|
||||
<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>
|
||||
@@ -0,0 +1,170 @@
|
||||
<?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>
|
||||