Compare commits
90 Commits
v1.14.1
...
b5fe61279b
| Author | SHA1 | Date | |
|---|---|---|---|
| b5fe61279b | |||
| e07886ecb1 | |||
| 4a3e12c85a | |||
| 54bc31c619 | |||
| d89438040b | |||
| 363f1632b1 | |||
| f0b7ad47d4 | |||
| 0e11817654 | |||
| d0e473920c | |||
| 0e56e4de9a | |||
| 8d3f862b41 | |||
| 74842dc624 | |||
| 1507ceb793 | |||
| 4b3c9d4fdd | |||
| eef90a152e | |||
| d8d1b06eee | |||
| c16008e67a | |||
| ff55d5de83 | |||
| 7cc2b50d23 | |||
| eb7fa98426 | |||
| 81d5383c42 | |||
| b6af682381 | |||
| ed79c30d2a | |||
| 471fadc6a4 | |||
| 564abdcf88 | |||
| c522e77599 | |||
| 28afcff551 | |||
| c34e2a8dbf | |||
| 25381f70a3 | |||
| 522beec82e | |||
| 3b485883ee | |||
| efeed17abc | |||
| cc34622630 | |||
| f6d256215a | |||
| fe5a3d6d3b | |||
| 7e142d471c | |||
| dbe2c4cb94 | |||
| 8c5b659ddf | |||
| a067a2fd01 | |||
| 8becdb45e4 | |||
| 25b5ed5516 | |||
| de5ab9186c | |||
| 7df078f5ed | |||
| 1d77ee83b3 | |||
| 5a4c460268 | |||
| 807edff327 | |||
| 0f3b4dbcff | |||
| 93ccc8c449 | |||
| e0360c98e2 | |||
| 11f89f6477 | |||
| 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 | |||
| a241e43d79 | |||
| 8ce986922a | |||
| 3469278260 | |||
| c66bf1eceb | |||
| 7b4d2421e2 | |||
| bf46b9492d | |||
| 1e1117c28e | |||
| 77a690fcf6 |
@@ -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 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.
|
||||||
|
- **VK ID web login** — raw OAuth 2.1 against `id.vk.com`, a **separate VK "Web" app** from the Mini
|
||||||
|
App, server-side confidential code exchange.
|
||||||
|
- **Email relay** — Selectel SMTP + confirm / link / unlink / deletion codes + alerts.
|
||||||
|
- **Native Android** — Capacitor bundle model, client-version gate, offline-first, RuStore; the
|
||||||
|
design lives in `docs/ARCHITECTURE.md` (§2 gate, §3 identity, §13 native build).
|
||||||
@@ -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_RUSTORE_* 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).
|
||||||
|
# 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_RUSTORE_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_RUSTORE_KEYSTORE_BASE64 secret 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_RUSTORE_KEYSTORE_PASSWORD }}
|
||||||
|
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }}
|
||||||
|
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_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
|
# 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.
|
# App above. One VK ID "Web" app serves every contour -> unprefixed secret.
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_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
|
# 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.
|
# 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 }}
|
GATEWAY_HONEYTOKEN: ${{ secrets.TEST_GATEWAY_HONEYTOKEN }}
|
||||||
@@ -510,19 +515,64 @@ jobs:
|
|||||||
- name: Probe the /offer/ public offer page is served
|
- name: Probe the /offer/ public offer page is served
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
# /offer/ is a static page baked into the landing image (rendered from
|
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
|
||||||
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
|
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
|
||||||
# silently falls through to the landing shell (also 200) — so assert offer-specific
|
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
|
||||||
# content, never just the status.
|
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
|
||||||
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
|
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
|
||||||
if echo "$out" | grep -q "290210610742"; then
|
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
|
||||||
echo "ok: /offer/ serves the public offer page"
|
# the marker with nothing, so "pricing_template" must be absent either way.
|
||||||
else
|
# Full body is needed (the INN is in §11 and the marker check spans the page), so this
|
||||||
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
|
# cannot be a head-only probe like the legal one. Retry with a timeout instead: the offer is
|
||||||
|
# now bilingual (RU + EN, larger), and a single-shot fetch can race the renderer's restart in
|
||||||
|
# the same deploy.
|
||||||
|
ok=
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
out="$(docker run --rm --network edge alpine:3.20 wget -q -T 10 -O - http://scrabble/offer/ 2>&1 || true)"
|
||||||
|
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
|
||||||
|
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ -z "$ok" ]; then
|
||||||
|
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
|
docker logs --tail 50 scrabble-landing || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Probe the /privacy/ and /eula/ legal pages are served
|
||||||
|
run: |
|
||||||
|
set -u
|
||||||
|
# /privacy/ and /eula/ are static legal markdown rendered by the render sidecar. If the
|
||||||
|
# @legal caddy route is missing they fall to the landing shell (also 200, canonical
|
||||||
|
# erudit-game.ru/), so assert the page-specific canonical URL — it uniquely identifies the
|
||||||
|
# rendered legal doc reaching the edge, not the landing catch-all. Read only the <head>
|
||||||
|
# (head -c 4096; the canonical link sits in the first bytes): the check is then
|
||||||
|
# size-independent, so the large EULA page does not exceed the timeout while the monitoring
|
||||||
|
# stack is still booting post-deploy and starving the CPU-capped renderer. Retry like the
|
||||||
|
# landing/gateway/backend probe to ride out that window.
|
||||||
|
for route in privacy eula; do
|
||||||
|
ok=
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
head_out="$(docker run --rm --network edge alpine:3.20 sh -c "wget -q -T 10 -O - http://scrabble/${route}/ 2>/dev/null | head -c 4096" || true)"
|
||||||
|
if printf '%s' "$head_out" | grep -qF "erudit-game.ru/${route}/"; then
|
||||||
|
echo "ok: /${route}/ serves the rendered legal page"
|
||||||
|
ok=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
if [ -z "$ok" ]; then
|
||||||
|
echo "FAIL: /${route}/ did not serve the rendered legal page (@legal route missing?)"
|
||||||
|
docker logs --tail 50 scrabble-renderer || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
- name: Probe the /pay/ callback route reaches the gateway
|
- name: Probe the /pay/ callback route reaches the gateway
|
||||||
run: |
|
run: |
|
||||||
set -u
|
set -u
|
||||||
|
|||||||
@@ -112,9 +112,20 @@ jobs:
|
|||||||
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
# derived from PUBLIC_BASE_URL in deploy/write-prod-env.sh.
|
||||||
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
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.
|
# 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.
|
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
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).
|
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
|
||||||
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
# Transactional email via the shared Selectel relay (confirm-codes): one account for
|
# 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 }}
|
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
|
||||||
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
|
||||||
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
|
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 }}
|
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
|
||||||
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
|
||||||
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
|
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/`
|
the single `.gitea/workflows/ci.yaml`. Committed edge codegen under `ui/src/gen/`
|
||||||
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
(regenerate with `pnpm codegen`); pnpm build-script approval lives in
|
||||||
`ui/pnpm-workspace.yaml` (`allowBuilds: esbuild: true`).
|
`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
|
||||||
|
|||||||
@@ -39,10 +39,13 @@ status — without re-deriving decisions.
|
|||||||
| E7 | Admin, reports & catalog | 2 | DONE |
|
| E7 | Admin, reports & catalog | 2 | DONE |
|
||||||
| E8 | Guest limits | — | DONE |
|
| E8 | Guest limits | — | DONE |
|
||||||
| E9 | Tournament fee | future | TODO |
|
| E9 | Tournament fee | future | TODO |
|
||||||
|
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | DONE |
|
||||||
|
| E11 | Payment availability kill switch + per-account override | 2 | DONE |
|
||||||
|
|
||||||
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
**Release 1** = full mechanics with no real money, exercised via `admin_grant` (E0→E1→E2→E3).
|
||||||
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
**Release 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
|
||||||
parallel). E9 is future.
|
parallel). E9 is future. E10 splits the `direct` rail into per-channel Robokassa shops + ИП
|
||||||
|
fiscalization (post-launch, backend-first).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -892,6 +895,123 @@ tournament-entry storage + pricing design.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## E10 — Multi-shop direct rail + ИП fiscalization
|
||||||
|
|
||||||
|
**Status:** DONE (merged — the multi-shop PR) · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`,
|
||||||
|
the `robokassa` adapter, the Result callback), E7 (the per-user report — channel breakdown) ·
|
||||||
|
mechanics: PAYMENTS §9, §12; decisions D41 (rev), D42–D44.
|
||||||
|
|
||||||
|
**Goal.** Split the single Robokassa `direct` rail into **per-channel merchant shops** (web,
|
||||||
|
android; ios later) — separate merchant accounts / withdrawal / accounting and a per-channel
|
||||||
|
breakdown in the E7 report — while keeping **one `direct` wallet** (D42). Land the wallet/identity
|
||||||
|
rules the native apps need (email-only anchor, D43) and revise fiscalization for the owner's move
|
||||||
|
to **ИП / 54-ФЗ** (D41 rev). All **additive / contour-safe** and **money-live** → expand-contract
|
||||||
|
throughout.
|
||||||
|
|
||||||
|
**Locked decisions (owner interview 2026-07-14): D41 (rev), D42, D43, D44.** No wallet-model /
|
||||||
|
spend-wall change; the split is merchant-account routing under one `direct` segment. Route the shop
|
||||||
|
by the **trusted** `X-Platform` subtype, never a client field; unknown → `web`.
|
||||||
|
|
||||||
|
**B1 — Config: one shop → a set of shops.**
|
||||||
|
|
||||||
|
- `robokassa.Config` (single) → a **shops registry** keyed by channel (`web`, `android`; `ios`
|
||||||
|
later), each 4 fields (`MerchantLogin`/`Password1`/`Password2`/`IsTest`). New env
|
||||||
|
`BACKEND_ROBOKASSA_WEB_*`, `BACKEND_ROBOKASSA_ANDROID_*`.
|
||||||
|
- **Expand-contract, no flag-day:** the legacy `BACKEND_ROBOKASSA_*` seeds the `web` shop; add the
|
||||||
|
new vars; retire the legacy set after the prod rollout sets the split vars. `validate()`: a shop
|
||||||
|
with a login must carry both passwords (mirror the current check), per shop.
|
||||||
|
- Config/README + `deploy/.env.example` + the deploy ansible vars.
|
||||||
|
|
||||||
|
**B2 — Route the shop by channel (intake).**
|
||||||
|
|
||||||
|
- `handleWalletOrder` `SourceDirect` branch: pick the shop by `cxt.Subtype` (`web`/`android`;
|
||||||
|
unknown → `web`). Build the payment request from that shop. The subtype rides the trusted
|
||||||
|
gateway-injected `X-Platform` (`<kind>/<subtype>`, `parsePlatformHeader`) — no spoofable client
|
||||||
|
field. **Verify the gateway emits `direct/android` for the native build**; if it sends a bare
|
||||||
|
`direct/`, add the android subtype (small gateway change).
|
||||||
|
- Record the chosen `shop` on the order (feeds B5).
|
||||||
|
|
||||||
|
**B3 — Per-shop Result callbacks.**
|
||||||
|
|
||||||
|
- The callback must select the right `Password2` → each shop gets its own Result URL:
|
||||||
|
**per-shop public edge routes** (mirror the existing single Robokassa Result route, e.g.
|
||||||
|
`…/result/web`, `…/result/android`), each forwarded to the backend and verified with that shop's
|
||||||
|
config, then the same `Fund` (source stays `direct`). Add the routes to the **Caddyfile
|
||||||
|
`@gateway` matcher** (else they fall to the landing catch-all) **+ a CI probe per route**.
|
||||||
|
- Keep the legacy single route alive (shop = `web`) through the expand-contract window until the
|
||||||
|
cabinet Result URLs are cut over.
|
||||||
|
|
||||||
|
**B4 — ИП fiscalization (D41 rev) — RESOLVED (owner 2026-07-14): cabinet-side only.** The owner keeps
|
||||||
|
Robokassa's cabinet auto-fiscalization (a generic чек is acceptable for the ИП); the optional
|
||||||
|
itemized-`Receipt` / `Email` code below is **dropped** — not needed. (Kept for context.)
|
||||||
|
|
||||||
|
- **Owner/cabinet:** enable the 54-ФЗ cloud kassa in the Robokassa ЛКК (kassa + ОФД + СНО).
|
||||||
|
Required for ИП regardless of code.
|
||||||
|
- **Code (optional, itemized чеки):** send a one-line `Receipt` (pack name, qty 1, `sum`, `tax`
|
||||||
|
per СНО, `payment_object`/`payment_method`) + the customer `Email` (the D36 confirmed anchor) in
|
||||||
|
the payment request; extend the signature to `MerchantLogin:OutSum:InvId:Receipt:Password1`
|
||||||
|
(URL-encode `Receipt`). One line fits the current GET redirect; POST-form only as a URL-length
|
||||||
|
fallback. One feature, all shops. **Sequenced last** — the split (B1–B3, B5, B6) needs no
|
||||||
|
fiscalization. Supersedes the E5 §12 shop-side НПД receipt note.
|
||||||
|
|
||||||
|
**B5 — Channel in the report (D44).**
|
||||||
|
|
||||||
|
- Additive `shop` column on the order (default `web` / backfill from `origin`); a per-channel
|
||||||
|
breakdown in the E7 per-user report (D40). Expand-contract migration (nullable/defaulted column),
|
||||||
|
**contour-safe** (a schema touch → note the contour `DROP SCHEMA` step in `PRERELEASE.md`).
|
||||||
|
|
||||||
|
**B6 — Tests + docs.**
|
||||||
|
|
||||||
|
- unit: the `robokassa` shops registry + per-shop `VerifyResult` (right `Password2`);
|
||||||
|
signature-with-`Receipt` (B4); intake shop routing by subtype (web/android/unknown→web).
|
||||||
|
- integration: order→per-shop callback→credit (each route), a duplicate credits once, an expired
|
||||||
|
order still honoured; the `shop` recorded + reported.
|
||||||
|
- docs: `PAYMENTS.md` (+`_ru`) the multi-shop topology + the ИП/54-ФЗ receipt revision;
|
||||||
|
`deploy/README` + `.env.example` the new vars + the per-shop Result routes + probes;
|
||||||
|
`PRERELEASE.md` the `shop` column contour step.
|
||||||
|
|
||||||
|
**Done-criteria.** A `direct/web` order pays through the web shop and `direct/android` through the
|
||||||
|
android shop, each verified with its own `Password2`, both crediting one `direct` wallet exactly
|
||||||
|
once; the E7 report breaks payments down by channel; the split stays contour-safe (the legacy shop
|
||||||
|
still works until the cabinet cutover). B4 (fiscalization) verified when the owner's ИП/ОФД/СНО are
|
||||||
|
live.
|
||||||
|
|
||||||
|
**Notes/risks.** Edge routes fall through if not in the Caddyfile `@gateway` matcher — add a CI
|
||||||
|
probe (field note). The prod rolling deploy skips caddy on config-only changes — force-recreate on a
|
||||||
|
Caddyfile change. Money-live: expand-contract only, image rollback DB-safe. Confirm the gateway
|
||||||
|
emits `direct/android` for the native build before relying on subtype routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E11 — Payment availability kill switch + per-account override
|
||||||
|
|
||||||
|
**Status:** DONE · **Release 2** · depends on: E5 (intake/order), E7 (admin console) · mechanics:
|
||||||
|
PAYMENTS §9; decisions D45, D46.
|
||||||
|
|
||||||
|
**Delivered.** An operator disables purchases live from `/_gm` — a whole rail/channel or one account
|
||||||
|
— and the user sees a localized reason on the next purchase attempt. Motivation (owner): real apps
|
||||||
|
show broken payments with no explanation; this gives ops a live switch + a clear user message.
|
||||||
|
|
||||||
|
- **Rail kill switch** — `payments.rail_status` (per rail `direct:web` / `direct:android` / `vk` /
|
||||||
|
`telegram`): `enabled` + a per-language message, edited on the catalog page. **Fail-open** (no row
|
||||||
|
⇒ enabled, so payments are never accidentally killed). The intake gate — `CanPurchase` in
|
||||||
|
`handleWalletOrder`, before the order — returns `payment_unavailable` + the localized message;
|
||||||
|
orthogonal to the security gates.
|
||||||
|
- **Per-account override** — `payments.account_payment_override` (a row only for non-default; default
|
||||||
|
= no row, cleared by delete): allow / deny / default, edited on the user card. `allow` bypasses
|
||||||
|
**only** the ops rail switch, never the security gates (D46).
|
||||||
|
- **Wire** — the message rides an additive `ExecuteResponse.message` (envelope layer,
|
||||||
|
frozen-contract-safe; the gateway forwards a backend domain-error message via `DomainMessage`); the
|
||||||
|
client reads it into `GatewayError.message` and shows it on a `payment_unavailable` buy attempt.
|
||||||
|
- **Tests** — the pure gate `PurchaseGate` (unit, TDD); the store + gate + override end-to-end
|
||||||
|
(integration, migration `00016`); the client (svelte-check / vitest). **Docs** — PAYMENTS (+`_ru`),
|
||||||
|
decisions D45 / D46.
|
||||||
|
|
||||||
|
**Contour-safe:** additive migration (two new tables, no wipe); the wire add is additive; fail-open,
|
||||||
|
so nothing is disabled until an operator acts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Verification & CI (all stages)
|
## Verification & CI (all stages)
|
||||||
|
|
||||||
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
# Erudit — site icons + Open Graph card
|
# Icons
|
||||||
|
|
||||||
The favicon set and the `og:image` link-preview card for the public landing
|
The whole shipped icon set is sliced from **one master** — see
|
||||||
(`ui/landing.html`) and the SPA shell (`ui/index.html`). Same design language as the
|
[`brand/`](brand/) for the reference generator, the pinned font and the committed
|
||||||
[VK loading-screen logo](../vk/README.md): the wooden Erudit «Э» tile (score `8`),
|
outputs, and [`../../docs/ICON_BRANDBOOK.md`](../../docs/ICON_BRANDBOOK.md) for how the
|
||||||
with the wordmark on the app's dark board green (`ui/src/app.css` tokens).
|
mark is constructed. The per-platform target map (which file goes where) is in
|
||||||
|
[`../../docs/ICONS.md`](../../docs/ICONS.md).
|
||||||
| Output (committed to `ui/public/`) | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `favicon.svg` | Vector favicon, transparent; tile + «Э» only (the score is illegible below ~32 px). |
|
|
||||||
| `favicon.ico` | 32×32 PNG-in-ICO fallback (also answers the browsers' blind `/favicon.ico` probe). |
|
|
||||||
| `apple-touch-icon.png` | 180×180 opaque full-bleed tile; iOS masks its own corners. |
|
|
||||||
| `og-image.png` | 1200×630 card: tile + «Эрудит / Скрэббл — игра в слова». Referenced absolutely as `https://erudit-game.ru/og-image.png`. |
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
`build/extract.js` extracts the needed glyph outlines (tile glyphs + every wordmark
|
|
||||||
character) from LiberationSans (Arial-metric, the game's font stack) with their
|
|
||||||
advance widths into `build/glyphs.json` (committed). `build/generate.js` composes
|
|
||||||
plain SVG from those outlines — no font is needed at generation time — writes
|
|
||||||
`favicon.svg` and rasterises the PNG/ICO outputs by screenshotting the SVGs with the
|
|
||||||
`ui` package's Playwright chromium (`@playwright/test`); the `.ico` container is
|
|
||||||
assembled in-script (a single PNG entry). Raster bytes therefore depend on the
|
|
||||||
installed chromium version; the SVG sources are deterministic.
|
|
||||||
|
|
||||||
## Regenerate
|
|
||||||
|
|
||||||
Requirements: Node ≥ 18, `ui` installed (`pnpm install`, provides Playwright).
|
|
||||||
`extract.js` additionally needs `opentype.js` (`npm i opentype.js`); **`generate.js`
|
|
||||||
needs no extra packages**.
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd assets/icons
|
cd brand
|
||||||
|
npm i opentype.js
|
||||||
# 1. (optional) re-extract the glyphs — only if the font or the wordmark changes:
|
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/
|
||||||
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
|
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
|
||||||
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
|
|
||||||
|
|
||||||
# 2. regenerate everything in ui/public/:
|
|
||||||
node build/generate.js
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The earlier `build/` pipeline (LiberationSans glyph outlines → favicon/apple-touch/OG)
|
||||||
|
> was replaced by `brand/` when the icon was rebranded onto the Spectral «Э» master.
|
||||||
|
> The separate VK loading-screen animation lives in [`../vk/`](../vk/) and is unrelated.
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Erudit icon — brand master
|
||||||
|
|
||||||
|
The reference construction of the Erudit app icon. The authoritative, human-readable
|
||||||
|
spec lives in [`docs/ICON_BRANDBOOK.md`](../../../docs/ICON_BRANDBOOK.md); this folder
|
||||||
|
holds the machine reproduction of exactly what that document describes.
|
||||||
|
|
||||||
|
| File | What |
|
||||||
|
|------|------|
|
||||||
|
| `build-icon.mjs` | reference generator — every dimension is a fraction of the side, matching the brand book |
|
||||||
|
| `render-png.mjs` | rasterises an icon SVG to PNG via the `ui` package's Playwright chromium |
|
||||||
|
| `Spectral-Bold.ttf` | the «Э» typeface (SIL OFL, `Spectral-OFL.txt`) — pinned so the letter never drifts |
|
||||||
|
| `icon-light.svg` / `.png` | the light master (1024) |
|
||||||
|
| `icon-dark.svg` / `.png` | the dark master (1024) |
|
||||||
|
| `icon-construction.svg` / `.png` | the percent-annotated construction scheme (figure in the brand book) |
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons/brand
|
||||||
|
npm i opentype.js # the only extra dep; render uses ui's chromium
|
||||||
|
|
||||||
|
node build-icon.mjs --variant light > icon-light.svg
|
||||||
|
node build-icon.mjs --variant dark > icon-dark.svg
|
||||||
|
node build-icon.mjs --variant light --scheme > icon-construction.svg
|
||||||
|
|
||||||
|
node render-png.mjs icon-light.svg icon-light.png 1024
|
||||||
|
node render-png.mjs icon-dark.svg icon-dark.png 1024
|
||||||
|
node render-png.mjs icon-construction.svg icon-construction.png 2048
|
||||||
|
```
|
||||||
|
|
||||||
|
The SVGs are resolution-free; render at any size. Downstream slicing (favicon / PWA /
|
||||||
|
iOS / Android) is a separate step — see [`docs/ICONS.md`](../../../docs/ICONS.md).
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Spectral Project Authors (https://github.com/productiontype/Spectral)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
https://openfontlicense.org
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
// Generate the Android launcher resources from the committed layers, at every
|
||||||
|
// density, with NO inset (our layers already fill the 108 dp canvas) and a
|
||||||
|
// monochrome (themed) layer. Run build-set.mjs first (it writes the layer PNGs).
|
||||||
|
//
|
||||||
|
// npm i opentype.js # (only build-icon/build-set need it; this reads PNGs)
|
||||||
|
// node build-set.mjs && node build-android-res.mjs
|
||||||
|
//
|
||||||
|
// Writes ui/android/app/src/main/res/mipmap-*/ic_launcher{,_round,_foreground,
|
||||||
|
// _background,_monochrome}.png. The two mipmap-anydpi-v26/*.xml are committed by hand.
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = join(DIR, '..', '..', '..');
|
||||||
|
const RES = join(ROOT, 'ui', 'android', 'app', 'src', 'main', 'res');
|
||||||
|
const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
|
||||||
|
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
|
||||||
|
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
|
||||||
|
const MONO = uri(join(DIR, 'android-monochrome.png'));
|
||||||
|
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
|
||||||
|
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
|
||||||
|
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
|
||||||
|
// the master's corner ✻).
|
||||||
|
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png'));
|
||||||
|
|
||||||
|
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
|
||||||
|
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
|
||||||
|
|
||||||
|
const pw = await import(join(ROOT, 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ deviceScaleFactor: 1 });
|
||||||
|
const im = (u, s) => `<img src="${u}" width="${s}" height="${s}" style="display:block">`;
|
||||||
|
async function shot(html, s, transparent) {
|
||||||
|
await page.setViewportSize({ width: s, height: s });
|
||||||
|
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}#r{width:${s}px;height:${s}px}</style><div id=r>${html}</div>`, { waitUntil: 'networkidle' });
|
||||||
|
return page.locator('#r').screenshot({ omitBackground: !!transparent });
|
||||||
|
}
|
||||||
|
for (const [d, [fg, lg]] of Object.entries(D)) {
|
||||||
|
const dir = join(RES, `mipmap-${d}`);
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false));
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_foreground.png'), await shot(im(FG, fg), fg, true));
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
|
||||||
|
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
|
||||||
|
// composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
|
||||||
|
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false));
|
||||||
|
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
|
||||||
|
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
|
||||||
|
console.log('mipmap-' + d, 'ok');
|
||||||
|
}
|
||||||
|
await browser.close();
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use strict';
|
||||||
|
// Erudit app-icon — the reference generator. Every dimension below is a FRACTION
|
||||||
|
// of the icon side, so this file reads the same as docs/ICON_BRANDBOOK.md and the
|
||||||
|
// icon reproduces at any resolution. Emits one variant/layer's SVG to stdout.
|
||||||
|
//
|
||||||
|
// node build-icon.mjs --variant light|dark [--layer full|background|foreground] [--scheme] > icon.svg
|
||||||
|
//
|
||||||
|
// Layers (for Android adaptive icons):
|
||||||
|
// full the standalone master (rounded tile + mark) — iOS / PWA / favicon
|
||||||
|
// background wood + grain + light/shadow, full-bleed square (the OS applies the mask)
|
||||||
|
// foreground only «Э» + ✻, transparent, re-placed for the round mask (see FG_* below)
|
||||||
|
//
|
||||||
|
// Deps: opentype.js (npm i opentype.js). Font: ./Spectral-Bold.ttf (OFL, bundled).
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import opentype from 'opentype.js';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const has = f => args.includes('--' + f);
|
||||||
|
const val = (f, d) => { const i = args.indexOf('--' + f); return i >= 0 ? args[i + 1] : d; };
|
||||||
|
|
||||||
|
// ---- the design, in fractions of the side ----
|
||||||
|
const S = 1024; // reference side (the icon is resolution-free)
|
||||||
|
const RADIUS = 0.17; // tile corner radius (full master)
|
||||||
|
const STAR_BULB = 0.22; // spoke-end (and hub) radius, as fraction of star radius
|
||||||
|
const STAR_SPOKES = 6;
|
||||||
|
const GRAIN_COLS = 6, GRAIN_W = 1 / 32, GRAIN_SHIFT = 1 / 16, GRAIN_TILT = 4;
|
||||||
|
const SHEEN = 0.15; // white, transparent bottom-left -> this alpha top-right
|
||||||
|
const SHADE = 0.15; // black, this alpha bottom-left -> transparent top-right
|
||||||
|
|
||||||
|
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
|
||||||
|
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
|
||||||
|
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
|
||||||
|
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
|
||||||
|
// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
|
||||||
|
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
|
||||||
|
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
|
||||||
|
// legacy round icon) get more breathing room; the full-bleed master is untouched.
|
||||||
|
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
|
||||||
|
|
||||||
|
const PALETTE = {
|
||||||
|
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
|
||||||
|
dark: { wood: '#4a3316', ink: '#f2d9a0', grain: '#432e14' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Layers:
|
||||||
|
// full rounded tile + master mark (favicon.svg — browser rounds nothing)
|
||||||
|
// flat square tile + master mark (apple-touch, PWA "any" — OS rounds)
|
||||||
|
// background square tile, no mark (Android adaptive background)
|
||||||
|
// foreground mark only, transparent (Android adaptive foreground)
|
||||||
|
// maskable square tile + foreground mark (PWA maskable — safe-zone aware)
|
||||||
|
// monochrome foreground mark only, flat colour (Android 13+ themed layer)
|
||||||
|
const LAYERS = ['full', 'flat', 'background', 'foreground', 'maskable', 'monochrome'];
|
||||||
|
const variant = val('variant', 'light');
|
||||||
|
const layer = val('layer', 'full');
|
||||||
|
const P = PALETTE[variant];
|
||||||
|
if (!P) { console.error(`unknown --variant ${variant} (light|dark)`); process.exit(1); }
|
||||||
|
if (!LAYERS.includes(layer)) { console.error(`unknown --layer ${layer} (${LAYERS.join('|')})`); process.exit(1); }
|
||||||
|
|
||||||
|
const usesForegroundPlacement = ['foreground', 'maskable', 'monochrome'].includes(layer);
|
||||||
|
const drawBg = ['full', 'flat', 'background', 'maskable'].includes(layer);
|
||||||
|
const drawMark = layer !== 'background';
|
||||||
|
const MARK = usesForegroundPlacement ? FOREGROUND : MASTER;
|
||||||
|
const inkColour = layer === 'monochrome' ? val('mono', '#ffffff') : P.ink;
|
||||||
|
const RX = layer === 'full' ? RADIUS * S : 0; // only the standalone master keeps rounded corners
|
||||||
|
const font = opentype.parse(readFileSync(join(DIR, 'Spectral-Bold.ttf')).buffer);
|
||||||
|
|
||||||
|
// «Э» outline scaled so its ink bounding box is MARK.lh of the side, box centred on MARK.lc.
|
||||||
|
function letterSvg() {
|
||||||
|
const g = font.charToGlyph('Э');
|
||||||
|
const h0 = (() => { const b = g.getPath(0, 0, 1000).getBoundingBox(); return b.y2 - b.y1; })();
|
||||||
|
const scale = (MARK.lh * S) / h0;
|
||||||
|
const p = g.getPath(0, 0, 1000 * scale);
|
||||||
|
const bb = p.getBoundingBox();
|
||||||
|
const w = bb.x2 - bb.x1, h = bb.y2 - bb.y1;
|
||||||
|
const tx = MARK.lc[0] * S - (bb.x1 + w / 2);
|
||||||
|
const ty = MARK.lc[1] * S - (bb.y1 + h / 2);
|
||||||
|
return { svg: `<path transform="translate(${tx.toFixed(2)} ${ty.toFixed(2)})" d="${p.toPathData(2)}" fill="${inkColour}"/>`,
|
||||||
|
box: { x: bb.x1 + tx, y: bb.y1 + ty, w, h } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teardrop-spoked asterisk ✻: each spoke tapers to a point at the centre and ends
|
||||||
|
// in a round bulb; a central disc equal to the bulb fuses them.
|
||||||
|
function starPath() {
|
||||||
|
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, Rout = MARK.sd * S / 2;
|
||||||
|
const rb = Rout * STAR_BULB, R = Rout - rb;
|
||||||
|
const L = Math.sqrt(R * R - rb * rb), theta = Math.asin(rb / R);
|
||||||
|
const rot = (v, t) => [v[0] * Math.cos(t) - v[1] * Math.sin(t), v[0] * Math.sin(t) + v[1] * Math.cos(t)];
|
||||||
|
let d = '';
|
||||||
|
for (let k = 0; k < STAR_SPOKES; k++) {
|
||||||
|
const a = -Math.PI / 2 + k * 2 * Math.PI / STAR_SPOKES;
|
||||||
|
const u = [Math.cos(a), Math.sin(a)];
|
||||||
|
const d1 = rot(u, theta), d2 = rot(u, -theta);
|
||||||
|
const T1 = [cx + L * d1[0], cy + L * d1[1]], T2 = [cx + L * d2[0], cy + L * d2[1]];
|
||||||
|
d += `M${cx.toFixed(2)} ${cy.toFixed(2)} L${T1[0].toFixed(2)} ${T1[1].toFixed(2)} A${rb.toFixed(2)} ${rb.toFixed(2)} 0 1 0 ${T2[0].toFixed(2)} ${T2[1].toFixed(2)} Z `;
|
||||||
|
}
|
||||||
|
d += `M${cx} ${cy} m${-rb} 0 a${rb} ${rb} 0 1 0 ${2 * rb} 0 a${rb} ${rb} 0 1 0 ${-2 * rb} 0 Z`;
|
||||||
|
return `<path d="${d}" fill="${inkColour}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function grain() {
|
||||||
|
const colW = S / GRAIN_COLS, sw = GRAIN_W * S, shift = GRAIN_SHIFT * S;
|
||||||
|
const yTop = -0.2 * S, yBot = 1.2 * S, cy = S / 2;
|
||||||
|
let s = '';
|
||||||
|
for (let i = 1; i <= GRAIN_COLS; i++) {
|
||||||
|
const xr = i * colW - shift, x = xr - sw, cx = xr - sw / 2;
|
||||||
|
s += `<rect x="${x.toFixed(2)}" y="${yTop.toFixed(2)}" width="${sw.toFixed(2)}" height="${(yBot - yTop).toFixed(2)}" fill="${P.grain}" transform="rotate(${GRAIN_TILT} ${cx.toFixed(2)} ${cy.toFixed(2)})"/>`;
|
||||||
|
}
|
||||||
|
return `<g clip-path="url(#tile)">${s}</g>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let body = '';
|
||||||
|
const L = letterSvg();
|
||||||
|
if (drawBg) { // tile + grain + light/shadow (the background)
|
||||||
|
body += `<rect width="${S}" height="${S}" rx="${RX}" fill="${P.wood}"/>`
|
||||||
|
+ `<defs><clipPath id="tile"><rect width="${S}" height="${S}" rx="${RX}"/></clipPath>`
|
||||||
|
+ `<linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="${SHEEN}"/></linearGradient>`
|
||||||
|
+ `<linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="${S}" x2="${S}" y2="0"><stop offset="0" stop-color="#000" stop-opacity="${SHADE}"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient>`
|
||||||
|
+ `</defs>`
|
||||||
|
+ grain()
|
||||||
|
+ `<rect width="${S}" height="${S}" fill="url(#sheen)" clip-path="url(#tile)"/>`
|
||||||
|
+ `<rect width="${S}" height="${S}" fill="url(#shade)" clip-path="url(#tile)"/>`;
|
||||||
|
}
|
||||||
|
if (drawMark) body += L.svg + starPath(); // the mark
|
||||||
|
if (has('scheme')) body += scheme(L);
|
||||||
|
|
||||||
|
process.stdout.write(`<svg xmlns="http://www.w3.org/2000/svg" width="${S}" height="${S}" viewBox="0 0 ${S} ${S}">${body}</svg>\n`);
|
||||||
|
|
||||||
|
// Percent-based construction overlay for the brand book (full master only).
|
||||||
|
function scheme(L) {
|
||||||
|
const pc = n => (100 * n / S).toFixed(1) + '%';
|
||||||
|
const GC = '#0f172a', BX = '#d81b60', AC = '#0d9488';
|
||||||
|
const halo = 'paint-order="stroke" stroke="#fff" stroke-width="4"';
|
||||||
|
const cx = MARK.sc[0] * S, cy = MARK.sc[1] * S, sd = MARK.sd * S;
|
||||||
|
let g = `<g font-family="'Helvetica Neue',Arial,sans-serif">`;
|
||||||
|
for (let f = 1; f <= 3; f++) {
|
||||||
|
const p = f * S / 4;
|
||||||
|
g += `<line x1="${p}" y1="0" x2="${p}" y2="${S}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||||
|
g += `<line x1="0" y1="${p}" x2="${S}" y2="${p}" stroke="${GC}" stroke-opacity="0.18" stroke-width="1.2"/>`;
|
||||||
|
g += `<text x="${p + 4}" y="20" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||||
|
g += `<text x="4" y="${p - 4}" font-size="17" font-weight="700" fill="${GC}" ${halo}>${f * 25}%</text>`;
|
||||||
|
}
|
||||||
|
g += `<line x1="${0.14 * S}" y1="${0.86 * S}" x2="${0.86 * S}" y2="${0.14 * S}" stroke="${AC}" stroke-width="3" stroke-dasharray="3 8" stroke-linecap="round"/>`;
|
||||||
|
g += `<circle cx="${0.86 * S}" cy="${0.14 * S}" r="6" fill="${AC}"/><circle cx="${0.14 * S}" cy="${0.86 * S}" r="6" fill="${AC}"/>`;
|
||||||
|
g += `<text x="${0.86 * S - 6}" y="${0.14 * S - 12}" text-anchor="end" font-size="18" font-weight="700" fill="${AC}" ${halo}>light: white 0%→15%</text>`;
|
||||||
|
g += `<text x="${0.14 * S + 10}" y="${0.86 * S + 26}" font-size="18" font-weight="700" fill="${AC}" ${halo}>shadow: black 15%→0%</text>`;
|
||||||
|
const { x, y, w, h } = L.box;
|
||||||
|
g += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${w.toFixed(1)}" height="${h.toFixed(1)}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<line x1="${MARK.lc[0] * S}" y1="${MARK.lc[1] * S - 16}" x2="${MARK.lc[0] * S}" y2="${MARK.lc[1] * S + 16}" stroke="${BX}" stroke-width="2.5"/><line x1="${MARK.lc[0] * S - 16}" y1="${MARK.lc[1] * S}" x2="${MARK.lc[0] * S + 16}" y2="${MARK.lc[1] * S}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<text x="${x.toFixed(1)}" y="${(y - 10).toFixed(1)}" font-size="19" font-weight="700" fill="${BX}" ${halo}>«Э» Spectral Bold · box H ${pc(h)} · W ${pc(w)}</text>`;
|
||||||
|
g += `<text x="${x.toFixed(1)}" y="${(y + h + 24).toFixed(1)}" font-size="18" font-weight="700" fill="${BX}" ${halo}>box center ${pc(MARK.lc[0] * S)} / ${pc(MARK.lc[1] * S)}</text>`;
|
||||||
|
g += `<rect x="${cx - sd / 2}" y="${cy - sd / 2}" width="${sd}" height="${sd}" fill="none" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<line x1="${cx}" y1="${cy - 14}" x2="${cx}" y2="${cy + 14}" stroke="${BX}" stroke-width="2.5"/><line x1="${cx - 14}" y1="${cy}" x2="${cx + 14}" y2="${cy}" stroke="${BX}" stroke-width="2.5"/>`;
|
||||||
|
g += `<text x="${(cx + sd / 2).toFixed(1)}" y="${(cy - sd / 2 - 10).toFixed(1)}" text-anchor="end" font-size="18" font-weight="700" fill="${BX}" ${halo}>✻ Ø ${pc(sd)} · center ${pc(cx)} / ${pc(cy)}</text>`;
|
||||||
|
g += `<text x="${RX + 14}" y="${RX - 6}" font-size="17" font-weight="700" fill="${GC}" ${halo}>corner r = 17%</text>`;
|
||||||
|
g += `<path d="M4 ${RX} A ${RX} ${RX} 0 0 1 ${RX} 4" fill="none" stroke="${GC}" stroke-width="2" stroke-dasharray="5 5"/>`;
|
||||||
|
g += `<rect x="${0.03 * S}" y="${S - 96}" width="${0.94 * S}" height="74" rx="12" fill="#0f172a" fill-opacity="0.82"/>`;
|
||||||
|
g += `<text x="${0.055 * S}" y="${S - 64}" font-size="18" font-weight="600" fill="#fff">Wood grain: 6 equal columns; a vertical stripe on each column's right edge,</text>`;
|
||||||
|
g += `<text x="${0.055 * S}" y="${S - 40}" font-size="18" font-weight="600" fill="#fff">width 1/32 of side; the whole set shifted left 1/16; every stripe tilted 4°.</text>`;
|
||||||
|
return g + `</g>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use strict';
|
||||||
|
// Slice the whole shipped icon set from the brand master (build-icon.mjs). Renders
|
||||||
|
// with the ui package's Playwright chromium. See docs/ICONS.md for the target map.
|
||||||
|
//
|
||||||
|
// npm i opentype.js && node build-set.mjs
|
||||||
|
//
|
||||||
|
// Writes: ui/public/* (web), ui/assets/* (Capacitor layers), ./vk/* and ./store/*
|
||||||
|
// (manual-upload art). Wiring (manifest, html, Android res) is done separately.
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const UI = join(DIR, '..', '..', '..', 'ui');
|
||||||
|
const PUB = join(UI, 'public'), ASSETS = join(UI, 'assets');
|
||||||
|
const VK = join(DIR, 'vk'), STORE = join(DIR, 'store'), TG = join(DIR, 'tg');
|
||||||
|
[VK, STORE, TG].forEach(d => mkdirSync(d, { recursive: true }));
|
||||||
|
|
||||||
|
// one SVG string for a variant+layer from the reference generator
|
||||||
|
const svgFor = (variant, layer) =>
|
||||||
|
execFileSync('node', [join(DIR, 'build-icon.mjs'), '--variant', variant, '--layer', layer], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
const pw = await import(join(UI, 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
async function pngFromSvg(svg, size, transparent) {
|
||||||
|
await page.setViewportSize({ width: size, height: size });
|
||||||
|
await page.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}svg{display:block;width:${size}px;height:${size}px}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||||
|
return page.locator('svg').screenshot({ omitBackground: !!transparent });
|
||||||
|
}
|
||||||
|
async function shootHtml(html, w, h) {
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await page.setContent(html, { waitUntil: 'networkidle' });
|
||||||
|
return page.screenshot();
|
||||||
|
}
|
||||||
|
function icoFromPNG(png, sizePx) {
|
||||||
|
const h = Buffer.alloc(22);
|
||||||
|
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4);
|
||||||
|
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7);
|
||||||
|
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12);
|
||||||
|
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18);
|
||||||
|
return Buffer.concat([h, png]);
|
||||||
|
}
|
||||||
|
const write = (p, buf) => { writeFileSync(p, buf); console.log('wrote', p.replace(join(DIR, '..', '..', '..'), '.'), buf.length, 'b'); };
|
||||||
|
|
||||||
|
// ---- pre-render the SVG sources we need ----
|
||||||
|
const S = {
|
||||||
|
fullLight: svgFor('light', 'full'), fullDark: svgFor('dark', 'full'),
|
||||||
|
flatLight: svgFor('light', 'flat'), flatDark: svgFor('dark', 'flat'),
|
||||||
|
maskLight: svgFor('light', 'maskable'), maskDark: svgFor('dark', 'maskable'),
|
||||||
|
bgLight: svgFor('light', 'background'), fgLight: svgFor('light', 'foreground'),
|
||||||
|
monoLight: svgFor('light', 'monochrome'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- favicon.svg: light + dark in one file, toggled by prefers-color-scheme ----
|
||||||
|
const inner = svg => svg.replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '');
|
||||||
|
const faviconSvg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">`
|
||||||
|
+ `<style>.d{display:none}@media(prefers-color-scheme:dark){.l{display:none}.d{display:inline}}</style>`
|
||||||
|
+ `<g class="l">${inner(S.fullLight)}</g><g class="d">${inner(S.fullDark)}</g></svg>\n`;
|
||||||
|
write(join(PUB, 'favicon.svg'), Buffer.from(faviconSvg));
|
||||||
|
|
||||||
|
// ---- web rasters ----
|
||||||
|
write(join(PUB, 'favicon.ico'), icoFromPNG(await pngFromSvg(S.flatLight, 32, false), 32));
|
||||||
|
write(join(PUB, 'apple-touch-icon.png'), await pngFromSvg(S.flatLight, 180, false));
|
||||||
|
write(join(PUB, 'icon-192.png'), await pngFromSvg(S.flatLight, 192, false));
|
||||||
|
write(join(PUB, 'icon-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||||
|
write(join(PUB, 'icon-192-dark.png'), await pngFromSvg(S.flatDark, 192, false));
|
||||||
|
write(join(PUB, 'icon-512-dark.png'), await pngFromSvg(S.flatDark, 512, false));
|
||||||
|
write(join(PUB, 'icon-maskable-512.png'), await pngFromSvg(S.maskLight, 512, false));
|
||||||
|
write(join(PUB, 'icon-maskable-512-dark.png'), await pngFromSvg(S.maskDark, 512, false));
|
||||||
|
|
||||||
|
// ---- Capacitor layers (ui/assets) ----
|
||||||
|
write(join(ASSETS, 'icon.png'), await pngFromSvg(S.flatLight, 1024, false)); // legacy single
|
||||||
|
write(join(ASSETS, 'icon-foreground.png'), await pngFromSvg(S.fgLight, 1024, true)); // adaptive fg
|
||||||
|
write(join(ASSETS, 'icon-background.png'), await pngFromSvg(S.bgLight, 1024, false)); // adaptive bg
|
||||||
|
write(join(DIR, 'android-monochrome.png'), await pngFromSvg(S.monoLight, 1024, true)); // themed layer (wired manually)
|
||||||
|
|
||||||
|
// ---- VK (no rounding — flat light) ----
|
||||||
|
for (const px of [576, 278, 150, 32]) write(join(VK, `vk-${px}.png`), await pngFromSvg(S.flatLight, px, false));
|
||||||
|
|
||||||
|
// ---- Telegram bot: square, no rounding (TG crops the avatar to a circle) ----
|
||||||
|
write(join(TG, 'tg-512.png'), await pngFromSvg(S.flatLight, 512, false)); // bot avatar + Mini App icon
|
||||||
|
|
||||||
|
// ---- store art ----
|
||||||
|
write(join(STORE, 'rustore-512.png'), await pngFromSvg(S.flatLight, 512, false));
|
||||||
|
|
||||||
|
// ---- wordmark banner (board-green bg + master tile + «Эрудит» / Игра в слова) ----
|
||||||
|
const b64 = readFileSync(join(DIR, 'Spectral-Bold.ttf')).toString('base64');
|
||||||
|
const banner = (w, h, tile, t1, t2, gap, pad) => `<!doctype html><meta charset=utf8><style>
|
||||||
|
@font-face{font-family:Spectral;src:url(data:font/ttf;base64,${b64});font-weight:700}
|
||||||
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
|
body{width:${w}px;height:${h}px;background:#2a3330;display:flex;align-items:center;gap:${gap}px;padding:0 ${pad}px;font-family:Spectral}
|
||||||
|
.tile{width:${tile}px;height:${tile}px;flex:none}.tile svg{width:${tile}px;height:${tile}px;display:block}
|
||||||
|
.wm{color:#e7ece8;text-align:center}.t1{font-weight:700;font-size:${t1}px;line-height:1.02;letter-spacing:-2px}
|
||||||
|
.t2{font-weight:700;font-size:${t2}px;line-height:1.1;color:#cdd6cf;margin-top:6px}
|
||||||
|
</style><div class=tile>${S.fullLight}</div><div class=wm><div class=t1>«Эрудит»</div><div class=t2>Игра в слова</div></div>`;
|
||||||
|
write(join(PUB, 'og-image.png'), await shootHtml(banner(1200, 630, 300, 150, 74, 64, 96), 1200, 630));
|
||||||
|
write(join(TG, 'tg-demo-640x360.png'), await shootHtml(banner(640, 360, 150, 72, 38, 30, 44), 640, 360));
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log('done');
|
||||||
|
After Width: | Height: | Size: 477 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 288 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#f2d9a0"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#f2d9a0"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 405 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#4a3316"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#432e14" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#f2d9a0"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#f2d9a0"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 290 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="0" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="0"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><path transform="translate(237.62 774.88)" d="M250.04-34.91Q326.35-34.91 368.57-91.33Q410.78-147.75 410.78-243.55Q410.78-249.23 410.78-254.91L254.10-254.91L208.64-185.91L195.65-185.91L195.65-368.57L208.64-368.57L254.10-299.56L407.53-299.56Q396.98-392.92 351.52-447.72Q306.06-502.52 241.11-502.52Q217.57-502.52 198.90-498.46Q180.22-494.40 162.36-482.22L83.62-354.77L69-354.77L77.93-505.76Q112.84-522.81 154.25-533.37Q195.65-543.92 259.78-543.92Q319.86-543.92 371.41-523.22Q422.96-502.52 460.71-465.58Q498.46-428.64 519.57-378.31Q540.67-327.98 540.67-267.90Q540.67-188.34 504.55-125.83Q468.42-63.32 405.10-27.60Q341.78 8.12 259.78 8.12Q200.52 8.12 150.59-3.25Q100.67-14.61 67.38-34.10L56.02-171.29L69.82-171.29L142.88-63.32Q165.61-50.33 191.59-42.62Q217.57-34.91 250.04-34.91" fill="#5a3a12"/><path d="M781.00 744.04 L795.25 695.59 A14.85 14.85 0 1 0 766.76 695.59 Z M781.00 744.04 L830.08 732.15 A14.85 14.85 0 1 0 815.84 707.48 Z M781.00 744.04 L815.84 780.60 A14.85 14.85 0 1 0 830.08 755.93 Z M781.00 744.04 L766.76 792.49 A14.85 14.85 0 1 0 795.25 792.49 Z M781.00 744.04 L731.93 755.93 A14.85 14.85 0 1 0 746.17 780.60 Z M781.00 744.04 L746.17 707.48 A14.85 14.85 0 1 0 731.93 732.15 Z M781.0048 744.0384 m-14.845952 0 a14.845952 14.845952 0 1 0 29.691904 0 a14.845952 14.845952 0 1 0 -29.691904 0 Z" fill="#5a3a12"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 411 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><rect width="1024" height="1024" rx="174.08" fill="#e6b053"/><defs><clipPath id="tile"><rect width="1024" height="1024" rx="174.08"/></clipPath><linearGradient id="sheen" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="1" stop-color="#fff" stop-opacity="0.15"/></linearGradient><linearGradient id="shade" gradientUnits="userSpaceOnUse" x1="0" y1="1024" x2="1024" y2="0"><stop offset="0" stop-color="#000" stop-opacity="0.15"/><stop offset="1" stop-color="#000" stop-opacity="0"/></linearGradient></defs><g clip-path="url(#tile)"><rect x="74.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 90.67 512.00)"/><rect x="245.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 261.33 512.00)"/><rect x="416.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 432.00 512.00)"/><rect x="586.67" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 602.67 512.00)"/><rect x="757.33" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 773.33 512.00)"/><rect x="928.00" y="-204.80" width="32.00" height="1433.60" fill="#d8a54e" transform="rotate(4 944.00 512.00)"/></g><rect width="1024" height="1024" fill="url(#sheen)" clip-path="url(#tile)"/><rect width="1024" height="1024" fill="url(#shade)" clip-path="url(#tile)"/><path transform="translate(157.86 804.91)" d="M280.84-39.21Q366.55-39.21 413.96-102.58Q461.38-165.95 461.38-273.54Q461.38-279.93 461.38-286.31L285.40-286.31L234.34-208.80L219.75-208.80L219.75-413.96L234.34-413.96L285.40-336.46L457.73-336.46Q445.88-441.32 394.81-502.86Q343.75-564.41 270.81-564.41Q244.37-564.41 223.39-559.85Q202.42-555.29 182.36-541.62L93.92-398.46L77.50-398.46L87.53-568.06Q126.74-587.21 173.24-599.06Q219.75-610.91 291.78-610.91Q359.25-610.91 417.15-587.66Q475.05-564.41 517.45-522.92Q559.85-481.44 583.56-424.90Q607.27-368.37 607.27-300.90Q607.27-211.54 566.69-141.33Q526.12-71.12 454.99-31Q383.87 9.12 291.78 9.12Q225.22 9.12 169.14-3.65Q113.06-16.41 75.68-38.30L62.92-192.39L78.42-192.39L160.48-71.12Q186.01-56.53 215.19-47.87Q244.37-39.21 280.84-39.21" fill="#5a3a12"/><path d="M807.01 807.01 L825.70 743.46 A19.48 19.48 0 1 0 788.33 743.46 Z M807.01 807.01 L871.40 791.42 A19.48 19.48 0 1 0 852.71 759.05 Z M807.01 807.01 L852.71 854.97 A19.48 19.48 0 1 0 871.40 822.61 Z M807.01 807.01 L788.33 870.57 A19.48 19.48 0 1 0 825.70 870.57 Z M807.01 807.01 L742.63 822.61 A19.48 19.48 0 1 0 761.32 854.97 Z M807.01 807.01 L761.32 759.05 A19.48 19.48 0 1 0 742.63 791.42 Z M807.0144 807.0144 m-19.475456 0 a19.475456 19.475456 0 1 0 38.950912 0 a19.475456 19.475456 0 1 0 -38.950912 0 Z" fill="#5a3a12"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "erudit-icon-brand",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Reference generator for the Erudit app-icon set (see docs/ICON_BRANDBOOK.md).",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build-set.mjs && node build-android-res.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"opentype.js": "^1.3.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
'use strict';
|
||||||
|
// Rasterise an icon SVG to PNG at a given size, using the `ui` package's cached
|
||||||
|
// Playwright chromium (no extra install). Usage:
|
||||||
|
// node render-png.mjs <in.svg> <out.png> [size=1024]
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const pw = await import(join(DIR, '..', '..', '..', 'ui', 'node_modules', '@playwright', 'test', 'index.js'));
|
||||||
|
const { chromium } = pw.default ?? pw;
|
||||||
|
|
||||||
|
const [, , svgPath, pngPath, size] = process.argv;
|
||||||
|
const S = Number(size) || 1024;
|
||||||
|
const svg = readFileSync(svgPath, 'utf8');
|
||||||
|
const b = await chromium.launch();
|
||||||
|
const pg = await b.newPage({ viewport: { width: S, height: S }, deviceScaleFactor: 1 });
|
||||||
|
await pg.setContent(`<!doctype html><meta charset=utf8><style>*{margin:0;padding:0}</style>${svg}`, { waitUntil: 'networkidle' });
|
||||||
|
writeFileSync(pngPath, await pg.locator('svg').screenshot({ omitBackground: true }));
|
||||||
|
await b.close();
|
||||||
|
console.log('wrote', pngPath, S + 'px');
|
||||||
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -1,78 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Extract the glyph outlines the site icons and the og-image wordmark need from a
|
|
||||||
// grotesque font (LiberationSans = Arial-metric, matching the game's system-ui/Arial
|
|
||||||
// stack) and emit cubic-bezier contours per character, baseline at y=0, y-down,
|
|
||||||
// plus the advance width so generate.js can lay out words without the font.
|
|
||||||
// Same outline conversion as ../../vk/build/extract.js, generalised to a char set.
|
|
||||||
const opentype = require('opentype.js');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
|
|
||||||
const b = fs.readFileSync(FONT);
|
|
||||||
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
|
|
||||||
const FS = 1000; // em scale
|
|
||||||
|
|
||||||
// The tile glyphs («Э», «8») + every character of the og-image wordmark lines
|
|
||||||
// («Эрудит», «Скрэббл — игра в слова»). The space carries only an advance.
|
|
||||||
const CHARS = [...new Set('Э8рудитСкэббл—игра в слова')];
|
|
||||||
|
|
||||||
function glyphData(ch) {
|
|
||||||
const g = font.charToGlyph(ch);
|
|
||||||
const p = g.getPath(0, 0, FS); // baseline at y=0, y-down
|
|
||||||
const contours = [];
|
|
||||||
let cur = null, prev = null;
|
|
||||||
for (const c of p.commands) {
|
|
||||||
if (c.type === 'M') {
|
|
||||||
if (cur) contours.push(cur);
|
|
||||||
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'L') {
|
|
||||||
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'C') {
|
|
||||||
cur[cur.length - 1].o = [c.x1, c.y1];
|
|
||||||
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'Q') {
|
|
||||||
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
|
|
||||||
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
|
|
||||||
cur[cur.length - 1].o = c1;
|
|
||||||
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
|
|
||||||
prev = { x: c.x, y: c.y };
|
|
||||||
} else if (c.type === 'Z') {
|
|
||||||
if (cur && cur.length > 1) {
|
|
||||||
const last = cur[cur.length - 1], first = cur[0];
|
|
||||||
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
|
|
||||||
first.i = last.i; // fold the duplicate closing point into the first
|
|
||||||
cur.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur) { contours.push(cur); cur = null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur) contours.push(cur);
|
|
||||||
|
|
||||||
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
|
||||||
const out = contours.map(ct => {
|
|
||||||
const v = [], i = [], o = [];
|
|
||||||
ct.forEach(pt => {
|
|
||||||
v.push(pt.v);
|
|
||||||
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
|
|
||||||
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
|
|
||||||
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
|
|
||||||
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
|
|
||||||
});
|
|
||||||
return { i, o, v, c: true };
|
|
||||||
});
|
|
||||||
const bbox = out.length
|
|
||||||
? { x: minx, y: miny, w: maxx - minx, h: maxy - miny }
|
|
||||||
: { x: 0, y: 0, w: 0, h: 0 }; // the space has no outline
|
|
||||||
return { adv: g.advanceWidth * (FS / font.unitsPerEm), bbox, contours: out };
|
|
||||||
}
|
|
||||||
|
|
||||||
const glyphs = {};
|
|
||||||
for (const ch of CHARS) glyphs[ch] = glyphData(ch);
|
|
||||||
|
|
||||||
const out = __dirname + '/glyphs.json';
|
|
||||||
fs.writeFileSync(out, JSON.stringify({ em: FS, glyphs }));
|
|
||||||
console.log('wrote', out, fs.statSync(out).size, 'bytes;', CHARS.length, 'glyphs:', CHARS.join(''));
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// Site icons + the Open Graph card for the public landing, drawn from the same
|
|
||||||
// design as ../../vk (the wooden Erudit tile: face «Э», score «8») and the app's
|
|
||||||
// board palette (ui/src/app.css). Everything is composed as SVG from the committed
|
|
||||||
// glyph outlines (build/extract.js -> glyphs.json), so no font is needed at build
|
|
||||||
// time; the PNG/ICO rasters are screenshots taken with the ui package's Playwright
|
|
||||||
// chromium (@playwright/test re-exports the browser API). Outputs go straight to
|
|
||||||
// ui/public/:
|
|
||||||
// favicon.svg 96 viewBox, transparent, tile + «Э» (the score is illegible small)
|
|
||||||
// favicon.ico 32x32 PNG-in-ICO render of the same
|
|
||||||
// apple-touch-icon.png 180x180 opaque full-bleed tile (iOS masks its own corners)
|
|
||||||
// og-image.png 1200x630 card: tile + wordmark on the board green
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const G = JSON.parse(fs.readFileSync(path.join(__dirname, 'glyphs.json'), 'utf8'));
|
|
||||||
const UI = path.resolve(__dirname, '../../../ui');
|
|
||||||
const OUT = path.join(UI, 'public');
|
|
||||||
|
|
||||||
// ---- palette (vk loader tile + app.css board tokens) ------------------------
|
|
||||||
const FACE = '#D9B978', BORDER = '#B49559', GLYPH = '#1A1A1A';
|
|
||||||
const BOARD_DARK = '#2a3330', TEXT = '#e7ece8', TEXT_MUTED = '#cdd6cf';
|
|
||||||
|
|
||||||
// ---- glyph outlines -> SVG path data ----------------------------------------
|
|
||||||
const r2 = n => Math.round(n * 100) / 100;
|
|
||||||
// pathD renders one glyph's contours scaled by sc and translated by (tx, ty).
|
|
||||||
function pathD(g, sc, tx, ty) {
|
|
||||||
const pt = (v, d) => `${r2(v[0] * sc + tx + d[0] * sc)} ${r2(v[1] * sc + ty + d[1] * sc)}`;
|
|
||||||
const Z = [0, 0];
|
|
||||||
return g.contours.map(ct => {
|
|
||||||
const n = ct.v.length;
|
|
||||||
let d = `M${pt(ct.v[0], Z)}`;
|
|
||||||
for (let k = 1; k <= n; k++) {
|
|
||||||
const a = k - 1, b = k % n;
|
|
||||||
d += `C${pt(ct.v[a], ct.o[a])} ${pt(ct.v[b], ct.i[b])} ${pt(ct.v[b], Z)}`;
|
|
||||||
}
|
|
||||||
return d + 'Z';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
// glyphAt centres a glyph's bbox at (cx, cy) with the given pixel cap height, the
|
|
||||||
// same placement rule as the vk loader's glyph(). stroke fattens it slightly.
|
|
||||||
function glyphAt(ch, capPx, cx, cy, stroke, colour) {
|
|
||||||
const g = G.glyphs[ch], sc = capPx / g.bbox.h;
|
|
||||||
const d = pathD(g, sc, cx - (g.bbox.x + g.bbox.w / 2) * sc, cy - (g.bbox.y + g.bbox.h / 2) * sc);
|
|
||||||
return `<path d="${d}" fill="${colour}" stroke="${colour}" stroke-width="${r2(stroke)}"/>`;
|
|
||||||
}
|
|
||||||
// textLine lays out a string on a baseline from the per-glyph advances; capPx sets
|
|
||||||
// the capital height (measured on «Э»). Returns the combined path + the width.
|
|
||||||
function textLine(str, capPx, x, y, colour) {
|
|
||||||
const sc = capPx / G.glyphs['Э'].bbox.h;
|
|
||||||
let d = '', w = 0;
|
|
||||||
for (const ch of str) {
|
|
||||||
const g = G.glyphs[ch];
|
|
||||||
if (g.contours.length) d += pathD(g, sc, x + w, y);
|
|
||||||
w += g.adv * sc;
|
|
||||||
}
|
|
||||||
return { svg: `<path d="${d}" fill="${colour}"/>`, width: w };
|
|
||||||
}
|
|
||||||
// tile draws the rounded wooden tile centred at (cx, cy): size px wide/high, with
|
|
||||||
// the vk loader's corner (6/52) and rim proportions, «Э» and optionally the «8».
|
|
||||||
function tile(cx, cy, size, withScore) {
|
|
||||||
const h = size / 2, rx = size * (6 / 52), rim = size * (1.8 / 52);
|
|
||||||
let s = `<rect x="${r2(cx - h + rim / 2)}" y="${r2(cy - h + rim / 2)}" width="${r2(size - rim)}" height="${r2(size - rim)}" rx="${r2(rx)}" fill="${FACE}" stroke="${BORDER}" stroke-width="${r2(rim)}"/>`;
|
|
||||||
s += glyphAt('Э', size * (32 / 52), cx, cy - size * (1 / 52), size * (1 / 52), GLYPH);
|
|
||||||
if (withScore) s += glyphAt('8', size * (8.5 / 52), cx + size * (19.5 / 52), cy + size * (18.5 / 52), size * (0.5 / 52), GLYPH);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">${body}</svg>`;
|
|
||||||
|
|
||||||
// ---- favicon.svg (the committed vector master) -------------------------------
|
|
||||||
const favicon = svg(96, 96, tile(48, 48, 88, false)) + '\n';
|
|
||||||
|
|
||||||
// ---- apple-touch-icon: opaque full bleed, iOS applies its own corner mask ----
|
|
||||||
const appleTouch = svg(180, 180,
|
|
||||||
`<rect width="180" height="180" fill="${FACE}"/>` +
|
|
||||||
`<rect x="8" y="8" width="164" height="164" rx="18" fill="none" stroke="${BORDER}" stroke-width="4"/>` +
|
|
||||||
glyphAt('Э', 100, 90, 88, 3, GLYPH) +
|
|
||||||
glyphAt('8', 26, 146, 142, 1.5, GLYPH));
|
|
||||||
|
|
||||||
// ---- og-image: tile + wordmark, centred as one group on the board green ------
|
|
||||||
function ogImage() {
|
|
||||||
const W = 1200, H = 630, tileSize = 340, gap = 84;
|
|
||||||
const l1 = textLine('Эрудит', 112, 0, 0, TEXT);
|
|
||||||
const l2 = textLine('Скрэббл — игра в слова', 44, 0, 0, TEXT_MUTED);
|
|
||||||
const textW = Math.max(l1.width, l2.width);
|
|
||||||
const left = (W - (tileSize + gap + textW)) / 2;
|
|
||||||
const tx = left + tileSize + gap;
|
|
||||||
// Two baselines around the vertical centre; the tile centre sits between them.
|
|
||||||
const b1 = 295, b2 = 408;
|
|
||||||
const body =
|
|
||||||
`<rect width="${W}" height="${H}" fill="${BOARD_DARK}"/>` +
|
|
||||||
tile(left + tileSize / 2, H / 2, tileSize, true) +
|
|
||||||
textLine('Эрудит', 112, tx, b1, TEXT).svg +
|
|
||||||
textLine('Скрэббл — игра в слова', 44, tx, b2, TEXT_MUTED).svg;
|
|
||||||
console.log(`og-image: text ${Math.round(textW)}px wide, group left ${Math.round(left)}px`);
|
|
||||||
return svg(W, H, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- rasterisation (Playwright chromium from ui/node_modules) ----------------
|
|
||||||
async function shoot(page, markup, w, h, transparent) {
|
|
||||||
await page.setViewportSize({ width: w, height: h });
|
|
||||||
await page.setContent(`<body style="margin:0">${markup}</body>`);
|
|
||||||
return page.screenshot({ omitBackground: transparent });
|
|
||||||
}
|
|
||||||
// icoFromPNG wraps one PNG as a single-entry .ico (ICONDIR + ICONDIRENTRY + PNG).
|
|
||||||
function icoFromPNG(png, sizePx) {
|
|
||||||
const h = Buffer.alloc(22);
|
|
||||||
h.writeUInt16LE(0, 0); h.writeUInt16LE(1, 2); h.writeUInt16LE(1, 4); // icon, 1 image
|
|
||||||
h.writeUInt8(sizePx, 6); h.writeUInt8(sizePx, 7); // 32x32
|
|
||||||
h.writeUInt16LE(1, 10); h.writeUInt16LE(32, 12); // planes, 32bpp
|
|
||||||
h.writeUInt32LE(png.length, 14); h.writeUInt32LE(22, 18); // size, offset
|
|
||||||
return Buffer.concat([h, png]);
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
fs.writeFileSync(path.join(OUT, 'favicon.svg'), favicon);
|
|
||||||
const { chromium } = require(path.join(UI, 'node_modules', '@playwright/test'));
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const page = await browser.newPage();
|
|
||||||
const fav32 = await shoot(page, svg(32, 32, tile(16, 16, 29.33, false)), 32, 32, true);
|
|
||||||
fs.writeFileSync(path.join(OUT, 'favicon.ico'), icoFromPNG(fav32, 32));
|
|
||||||
fs.writeFileSync(path.join(OUT, 'apple-touch-icon.png'), await shoot(page, appleTouch, 180, 180, false));
|
|
||||||
fs.writeFileSync(path.join(OUT, 'og-image.png'), await shoot(page, ogImage(), 1200, 630, false));
|
|
||||||
await browser.close();
|
|
||||||
for (const f of ['favicon.svg', 'favicon.ico', 'apple-touch-icon.png', 'og-image.png']) {
|
|
||||||
console.log('wrote', path.join(OUT, f), fs.statSync(path.join(OUT, f)).size, 'bytes');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -283,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
}
|
}
|
||||||
logger.Info("payments domain ready")
|
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
|
// 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
|
// and the account-merge wallet fold. Done after the reachability check so a broken payments
|
||||||
// schema fails boot before anything depends on it.
|
// schema fails boot before anything depends on it.
|
||||||
|
|||||||
@@ -21,7 +21,29 @@
|
|||||||
<div><button type="submit">Add</button></div>
|
<div><button type="submit">Add</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||||
|
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||||
|
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||||
|
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||||
|
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||||
|
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><h2>Payment availability (kill switch)</h2>
|
||||||
|
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in “temporarily unavailable”. Fail-open: a rail you never touch stays on. A per-user “allow” override (on a user card) bypasses this switch.</p>
|
||||||
|
{{range .Rails}}
|
||||||
|
<form class="form col" method="post" action="/_gm/catalog/rail-status">
|
||||||
|
<input type="hidden" name="rail" value="{{.Rail}}">
|
||||||
|
<label><input type="checkbox" name="enabled" {{if .Enabled}}checked{{end}}> <code>{{.Rail}}</code> — purchases enabled</label>
|
||||||
|
<label>Message RU <input type="text" name="message_ru" maxlength="200" value="{{.MessageRU}}"></label>
|
||||||
|
<label>Message EN <input type="text" name="message_en" maxlength="200" value="{{.MessageEN}}"></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</section>
|
||||||
<section class="panel"><h2>Products</h2>
|
<section class="panel"><h2>Products</h2>
|
||||||
|
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
<li><b>Dictionary</b> {{.DictVersion}}</li>
|
||||||
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
|
||||||
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
|
||||||
|
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
|
||||||
<li><b>Players</b> {{.Players}}</li>
|
<li><b>Players</b> {{.Players}}</li>
|
||||||
<li><b>To move</b> seat {{.ToMove}}</li>
|
<li><b>To move</b> seat {{.ToMove}}</li>
|
||||||
<li><b>Moves</b> {{.MoveCount}}</li>
|
<li><b>Moves</b> {{.MoveCount}}</li>
|
||||||
|
|||||||
@@ -72,14 +72,24 @@
|
|||||||
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
{{if or .Finance.Abuse .Finance.Loss}}<li><b>Refund risk</b> <span class="warn">{{if .Finance.Abuse}}abuse-flagged{{end}}{{if .Finance.Loss}} · loss {{.Finance.Loss}} chips{{end}}</span></li>{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
{{else}}<p class="note">no balances or benefits</p>{{end}}
|
||||||
|
<h3>Payment override</h3>
|
||||||
|
<form class="form" method="post" action="/_gm/users/{{.ID}}/purchase-override">
|
||||||
|
<label>Purchases for this account
|
||||||
|
<select name="override">
|
||||||
|
<option value="default" {{if eq .PurchaseOverride "default"}}selected{{end}}>Default (follow the rail switch)</option>
|
||||||
|
<option value="allow" {{if eq .PurchaseOverride "allow"}}selected{{end}}>Always allow (bypasses the rail switch only, not security)</option>
|
||||||
|
<option value="deny" {{if eq .PurchaseOverride "deny"}}selected{{end}}>Always deny</option>
|
||||||
|
</select></label>
|
||||||
|
<div><button type="submit">Save</button></div>
|
||||||
|
</form>
|
||||||
<h3>Ledger</h3>
|
<h3>Ledger</h3>
|
||||||
{{$uid := .ID}}
|
{{$uid := .ID}}
|
||||||
{{if .Finance.Ledger}}
|
{{if .Finance.Ledger}}
|
||||||
<table class="list">
|
<table class="list">
|
||||||
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Detail</th><th></th></tr></thead>
|
<thead><tr><th>Time</th><th>Kind</th><th>Source</th><th>Origin</th><th>Chips</th><th>Order</th><th>Provider</th><th>Shop</th><th>Detail</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Finance.Ledger}}
|
{{range .Finance.Ledger}}
|
||||||
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
<tr><td>{{.At}}</td><td>{{.Kind}}</td><td>{{.Source}}</td><td>{{.Origin}}</td><td>{{.ChipsDelta}}</td><td>{{if .Order}}<code>{{.Order}}</code>{{end}}</td><td>{{.Provider}}</td><td>{{.Shop}}</td><td>{{if .Snapshot}}<code>{{.Snapshot}}</code>{{end}}</td>
|
||||||
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
<td>{{if and (eq .Kind "fund") .Order}}<form class="form" method="post" action="/_gm/users/{{$uid}}/refund" onsubmit="return confirm('Refund this order in full? Record the money refund on the rail first; this revokes the chips (floored at 0).')"><input type="hidden" name="order_id" value="{{.Order}}"><button type="submit">Refund</button></form>{{end}}</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ type UserDetailView struct {
|
|||||||
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
|
||||||
// is false when the payments domain is unwired.
|
// is false when the payments domain is unwired.
|
||||||
Finance FinanceView
|
Finance FinanceView
|
||||||
|
// PurchaseOverride is the account's per-account purchase override ("default"/"allow"/"deny"),
|
||||||
|
// shown in and edited from the user card's payment-override control.
|
||||||
|
PurchaseOverride string
|
||||||
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
// Grant is the admin-grant panel (origin picker + grantable products). Present is false when the
|
||||||
// payments domain is unwired.
|
// payments domain is unwired.
|
||||||
Grant GrantFormView
|
Grant GrantFormView
|
||||||
@@ -232,8 +235,8 @@ type BenefitRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
// LedgerRow is one append-only ledger entry: its kind, funding source / benefit origin, signed chip
|
||||||
// delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
|
// delta, the product / order / provider / direct-rail shop it references (empty when none), the raw
|
||||||
// the pre-formatted time.
|
// snapshot JSON and the pre-formatted time.
|
||||||
type LedgerRow struct {
|
type LedgerRow struct {
|
||||||
Kind string
|
Kind string
|
||||||
Source string
|
Source string
|
||||||
@@ -242,6 +245,7 @@ type LedgerRow struct {
|
|||||||
Product string
|
Product string
|
||||||
Order string
|
Order string
|
||||||
Provider string
|
Provider string
|
||||||
|
Shop string
|
||||||
Snapshot string
|
Snapshot string
|
||||||
At string
|
At string
|
||||||
}
|
}
|
||||||
@@ -348,8 +352,12 @@ type GameDetailView struct {
|
|||||||
UpdatedAt string
|
UpdatedAt string
|
||||||
FinishedAt string
|
FinishedAt string
|
||||||
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
|
||||||
VsAI bool
|
VsAI bool
|
||||||
Seats []SeatRow
|
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
|
||||||
|
// is validated and scored), false = the single-word rule (only the main word along the play
|
||||||
|
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
|
||||||
|
MultipleWordsPerTurn bool
|
||||||
|
Seats []SeatRow
|
||||||
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
// HasRobot is true when any seat is a robot, gating the robot-target caption;
|
||||||
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
// RobotTargetPct is the configured global play-to-win rate, in percent.
|
||||||
HasRobot bool
|
HasRobot bool
|
||||||
@@ -673,6 +681,27 @@ type FeedbackDetailView struct {
|
|||||||
// CatalogView is the product-catalog list page.
|
// CatalogView is the product-catalog list page.
|
||||||
type CatalogView struct {
|
type CatalogView struct {
|
||||||
Products []ProductRow
|
Products []ProductRow
|
||||||
|
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||||
|
// toggle); it drives the toggle link and the list heading.
|
||||||
|
ShowAll bool
|
||||||
|
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||||
|
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||||
|
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||||
|
RewardPayout int
|
||||||
|
RewardDailyCap int
|
||||||
|
RewardHourlyCap int
|
||||||
|
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
|
||||||
|
// in and edited from the page's payment-availability form.
|
||||||
|
Rails []RailStatusRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
|
||||||
|
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
|
||||||
|
type RailStatusRow struct {
|
||||||
|
Rail string
|
||||||
|
Enabled bool
|
||||||
|
MessageRU string
|
||||||
|
MessageEN string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ type Config struct {
|
|||||||
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
// RendererURL is the base URL of the internal image-render sidecar (e.g.
|
||||||
// http://renderer:8090). Empty disables the PNG export artifact.
|
// http://renderer:8090). Empty disables the PNG export artifact.
|
||||||
RendererURL string
|
RendererURL string
|
||||||
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
|
// Robokassa configures the direct-rail (RUB) payment provider — one merchant shop per channel
|
||||||
// leaves the direct order and Result-callback endpoints unregistered.
|
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered.
|
||||||
Robokassa robokassa.Config
|
Robokassa robokassa.Shops
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defaults applied when the corresponding environment variable is unset.
|
// Defaults applied when the corresponding environment variable is unset.
|
||||||
@@ -157,11 +157,19 @@ func Load() (Config, error) {
|
|||||||
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
robo := robokassa.Config{
|
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed
|
||||||
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
|
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop
|
||||||
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
|
// with no MerchantLogin is dropped (the rail stays dormant when none is configured).
|
||||||
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
|
shops := robokassa.Shops{}
|
||||||
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
|
web := robokassaShop("BACKEND_ROBOKASSA_WEB")
|
||||||
|
if web.MerchantLogin == "" {
|
||||||
|
web = robokassaShop("BACKEND_ROBOKASSA") // legacy single-shop credentials seed the web channel
|
||||||
|
}
|
||||||
|
if web.MerchantLogin != "" {
|
||||||
|
shops[robokassa.ChannelWeb] = web
|
||||||
|
}
|
||||||
|
if android := robokassaShop("BACKEND_ROBOKASSA_ANDROID"); android.MerchantLogin != "" {
|
||||||
|
shops[robokassa.ChannelAndroid] = android
|
||||||
}
|
}
|
||||||
|
|
||||||
c := Config{
|
c := Config{
|
||||||
@@ -181,7 +189,7 @@ func Load() (Config, error) {
|
|||||||
GuestRetention: guestRetention,
|
GuestRetention: guestRetention,
|
||||||
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
|
||||||
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
|
||||||
Robokassa: robo,
|
Robokassa: shops,
|
||||||
}
|
}
|
||||||
if err := c.validate(); err != nil {
|
if err := c.validate(); err != nil {
|
||||||
return Config{}, err
|
return Config{}, err
|
||||||
@@ -234,8 +242,10 @@ func (c Config) validate() error {
|
|||||||
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
|
for channel, shop := range c.Robokassa {
|
||||||
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
|
if shop.Password1 == "" || shop.Password2 == "" {
|
||||||
|
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -276,3 +286,15 @@ func envDuration(key string, fallback time.Duration) (time.Duration, error) {
|
|||||||
}
|
}
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// robokassaShop reads a Robokassa shop's four credentials from the environment under prefix (e.g.
|
||||||
|
// "BACKEND_ROBOKASSA_WEB" → _MERCHANT_LOGIN / _PASSWORD1 / _PASSWORD2 / _TEST). A missing
|
||||||
|
// MerchantLogin yields a zero Config the caller drops.
|
||||||
|
func robokassaShop(prefix string) robokassa.Config {
|
||||||
|
return robokassa.Config{
|
||||||
|
MerchantLogin: os.Getenv(prefix + "_MERCHANT_LOGIN"),
|
||||||
|
Password1: os.Getenv(prefix + "_PASSWORD1"),
|
||||||
|
Password2: os.Getenv(prefix + "_PASSWORD2"),
|
||||||
|
IsTest: os.Getenv(prefix+"_TEST") == "1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
all, err := pay.AdminCatalog(context.Background())
|
all, err := pay.AdminCatalog(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("admin catalog: %v", err)
|
t.Fatalf("admin catalog: %v", err)
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||||
}
|
}
|
||||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||||
t.Fatalf("catalog: %v", err)
|
t.Fatalf("catalog: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
|||||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||||
|
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||||
|
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||||
|
srv, _, _ := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const catalog = "http://admin.test/_gm/catalog"
|
||||||
|
|
||||||
|
// An active value and an archived one (created without the active flag).
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create active failed")
|
||||||
|
}
|
||||||
|
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||||
|
t.Fatal("create archived failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default view: active only.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?all=1: active and archived.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||||
|
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||||
|
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||||
|
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||||
|
func TestConsoleRewardConfig(t *testing.T) {
|
||||||
|
srv, _, pay := bannerServer(t)
|
||||||
|
h := srv.Handler()
|
||||||
|
const origin = "http://admin.test"
|
||||||
|
const reward = "http://admin.test/_gm/catalog/reward"
|
||||||
|
|
||||||
|
// Set the payout and caps.
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||||
|
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||||
|
}
|
||||||
|
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||||
|
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The catalog page renders the form pre-filled with the current payout.
|
||||||
|
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||||
|
t.Error("catalog page did not render the reward form with the current payout")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A negative value is refused (the service validates non-negativity).
|
||||||
|
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||||
|
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package inttest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"scrabble/backend/internal/payments"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPaymentAvailabilityKillSwitch exercises the per-rail kill switch + the per-account override
|
||||||
|
// end-to-end against Postgres: fail-open, a disabled rail with a localized message, per-rail
|
||||||
|
// granularity, and the allow/deny/default overrides.
|
||||||
|
func TestPaymentAvailabilityKillSwitch(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := newPaymentsService()
|
||||||
|
acc := uuid.New()
|
||||||
|
|
||||||
|
// Fail-open: an untouched rail is enabled.
|
||||||
|
if ok, _, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); err != nil || !ok {
|
||||||
|
t.Fatalf("fail-open: CanPurchase = %v/%v, want true", ok, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable the web rail with a per-language message → blocked with that message, localized.
|
||||||
|
if err := svc.SetRailStatus(ctx, payments.RailDirectWeb, payments.RailAvailability{MessageRU: "Чиним", MessageEN: "Fixing"}); err != nil {
|
||||||
|
t.Fatalf("set rail status: %v", err)
|
||||||
|
}
|
||||||
|
if ok, reason, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "ru"); err != nil || ok || reason != "Чиним" {
|
||||||
|
t.Fatalf("disabled rail (ru): CanPurchase = %v/%q/%v, want false/Чиним", ok, reason, err)
|
||||||
|
}
|
||||||
|
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); ok || reason != "Fixing" {
|
||||||
|
t.Fatalf("disabled rail (en): = %v/%q, want false/Fixing", ok, reason)
|
||||||
|
}
|
||||||
|
// Per-rail granularity: another rail stays enabled.
|
||||||
|
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
|
||||||
|
t.Fatalf("android rail should stay enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A per-account "allow" override bypasses the disabled rail.
|
||||||
|
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideAllow); err != nil {
|
||||||
|
t.Fatalf("set override allow: %v", err)
|
||||||
|
}
|
||||||
|
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); !ok {
|
||||||
|
t.Fatalf("allow override should bypass the disabled rail")
|
||||||
|
}
|
||||||
|
if ov, err := svc.PurchaseOverrideFor(ctx, acc); err != nil || ov != payments.OverrideAllow {
|
||||||
|
t.Fatalf("PurchaseOverrideFor = %v/%v, want allow", ov, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A "deny" override blocks even an enabled rail.
|
||||||
|
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDeny); err != nil {
|
||||||
|
t.Fatalf("set override deny: %v", err)
|
||||||
|
}
|
||||||
|
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); ok || reason == "" {
|
||||||
|
t.Fatalf("deny override should block the enabled android rail with a reason, got %v/%q", ok, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing the override (default) restores rail-driven behaviour.
|
||||||
|
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDefault); err != nil {
|
||||||
|
t.Fatalf("clear override: %v", err)
|
||||||
|
}
|
||||||
|
if ov, _ := svc.PurchaseOverrideFor(ctx, acc); ov != payments.OverrideDefault {
|
||||||
|
t.Fatalf("after clear, override = %v, want default", ov)
|
||||||
|
}
|
||||||
|
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
|
||||||
|
t.Fatalf("after clearing override, android rail should be enabled again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RailStatuses reflects the stored web-rail change and fills the rest fail-open.
|
||||||
|
all, err := svc.RailStatuses(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rail statuses: %v", err)
|
||||||
|
}
|
||||||
|
if all[payments.RailDirectWeb].Enabled {
|
||||||
|
t.Fatalf("web rail should read disabled")
|
||||||
|
}
|
||||||
|
if !all[payments.RailVK].Enabled {
|
||||||
|
t.Fatalf("untouched vk rail should read enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ package inttest
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
|
|||||||
t.Error("deactivated product must not appear in the storefront")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import "slices"
|
||||||
|
|
||||||
|
// PurchaseOverride is a per-account purchase override that forces purchases allowed or denied for
|
||||||
|
// one account regardless of the operational rail switch, or is absent (OverrideDefault) when the
|
||||||
|
// account follows the rail switch. The zero value is OverrideDefault, so a missing override row maps
|
||||||
|
// to it. OverrideAllow bypasses ONLY the operational rail switch — never the security / compliance
|
||||||
|
// gates (trusted platform, the D36 email anchor, the VK-iOS spend freeze, the min client version),
|
||||||
|
// which CreateOrder enforces separately.
|
||||||
|
type PurchaseOverride int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// OverrideDefault follows the rail switch (no override row for the account).
|
||||||
|
OverrideDefault PurchaseOverride = iota
|
||||||
|
// OverrideAllow always allows the purchase, bypassing only the operational rail switch.
|
||||||
|
OverrideAllow
|
||||||
|
// OverrideDeny always denies the purchase for the account.
|
||||||
|
OverrideDeny
|
||||||
|
)
|
||||||
|
|
||||||
|
// RailAvailability is a payment rail's operational availability: whether purchases are enabled and,
|
||||||
|
// when disabled, the operator's explanation in each language, shown to the user on a purchase
|
||||||
|
// attempt. A rail with no status row is treated as enabled (fail-open — see the store), so the
|
||||||
|
// zero value is never used as a live "enabled" default.
|
||||||
|
type RailAvailability struct {
|
||||||
|
Enabled bool
|
||||||
|
MessageRU string
|
||||||
|
MessageEN string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchaseGate decides whether an account may open a purchase order on a rail, from the per-account
|
||||||
|
// override and the rail's operational availability. It is the operational layer only — the caller
|
||||||
|
// still enforces the security gates. On a block it returns a reason localized to lang ("ru", else
|
||||||
|
// English). Fail-open: OverrideDefault on an enabled rail allows.
|
||||||
|
func PurchaseGate(override PurchaseOverride, rail RailAvailability, lang string) (ok bool, reason string) {
|
||||||
|
switch override {
|
||||||
|
case OverrideAllow:
|
||||||
|
return true, "" // bypasses only the ops switch; the security gates still apply upstream
|
||||||
|
case OverrideDeny:
|
||||||
|
return false, defaultUnavailable(lang) // a per-account block — a neutral reason
|
||||||
|
default: // OverrideDefault — follow the rail switch
|
||||||
|
if rail.Enabled {
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
return false, railMessage(rail, lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// railMessage returns the operator's rail-off message in lang, falling back to the other language
|
||||||
|
// and then to the built-in default when the operator left both blank.
|
||||||
|
func railMessage(rail RailAvailability, lang string) string {
|
||||||
|
if lang == "ru" {
|
||||||
|
if rail.MessageRU != "" {
|
||||||
|
return rail.MessageRU
|
||||||
|
}
|
||||||
|
if rail.MessageEN != "" {
|
||||||
|
return rail.MessageEN
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if rail.MessageEN != "" {
|
||||||
|
return rail.MessageEN
|
||||||
|
}
|
||||||
|
if rail.MessageRU != "" {
|
||||||
|
return rail.MessageRU
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultUnavailable(lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultUnavailable is the built-in localized "payments unavailable" reason used when the operator
|
||||||
|
// set no custom message, and for a per-account deny.
|
||||||
|
func defaultUnavailable(lang string) string {
|
||||||
|
if lang == "ru" {
|
||||||
|
return "Оплата временно недоступна. Попробуйте позже."
|
||||||
|
}
|
||||||
|
return "Payments are temporarily unavailable. Please try again later."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rail keys name the operational payment rails the kill switch and the per-account override key on.
|
||||||
|
// The direct rail is split per channel (D42); vk and telegram are single-rail.
|
||||||
|
const (
|
||||||
|
RailDirectWeb = "direct:web"
|
||||||
|
RailDirectAndroid = "direct:android"
|
||||||
|
RailVK = "vk"
|
||||||
|
RailTelegram = "telegram"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KnownRails is the fixed set of rail keys, for the admin editor and for validation.
|
||||||
|
var KnownRails = []string{RailDirectWeb, RailDirectAndroid, RailVK, RailTelegram}
|
||||||
|
|
||||||
|
// RailKey maps a payment context to its operational rail key: the direct rail by channel subtype
|
||||||
|
// (android, else web), or the store rail's own name (vk / telegram).
|
||||||
|
func RailKey(kind Source, subtype string) string {
|
||||||
|
if kind == SourceDirect {
|
||||||
|
if subtype == "android" {
|
||||||
|
return RailDirectAndroid
|
||||||
|
}
|
||||||
|
return RailDirectWeb
|
||||||
|
}
|
||||||
|
return string(kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isKnownRail reports whether rail is one of the fixed rail keys.
|
||||||
|
func isKnownRail(rail string) bool {
|
||||||
|
return slices.Contains(KnownRails, rail)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestPurchaseGate(t *testing.T) {
|
||||||
|
on := RailAvailability{Enabled: true}
|
||||||
|
offMsg := RailAvailability{Enabled: false, MessageRU: "Чиним", MessageEN: "Fixing"}
|
||||||
|
offNoMsg := RailAvailability{Enabled: false}
|
||||||
|
|
||||||
|
// OverrideDefault follows the rail switch.
|
||||||
|
if ok, _ := PurchaseGate(OverrideDefault, on, "en"); !ok {
|
||||||
|
t.Error("default + enabled rail should allow")
|
||||||
|
}
|
||||||
|
if ok, r := PurchaseGate(OverrideDefault, offMsg, "ru"); ok || r != "Чиним" {
|
||||||
|
t.Errorf("default + off rail (ru) = %v/%q, want false/Чиним", ok, r)
|
||||||
|
}
|
||||||
|
if ok, r := PurchaseGate(OverrideDefault, offMsg, "en"); ok || r != "Fixing" {
|
||||||
|
t.Errorf("default + off rail (en) = %v/%q, want false/Fixing", ok, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Off rail with no custom message → the built-in default, localized (not the English default in ru).
|
||||||
|
if ok, r := PurchaseGate(OverrideDefault, offNoMsg, "ru"); ok || r != defaultUnavailable("ru") {
|
||||||
|
t.Errorf("default + off no-msg (ru) = %v/%q, want false + the ru default", ok, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverrideAllow bypasses the ops switch even when the rail is off.
|
||||||
|
if ok, r := PurchaseGate(OverrideAllow, offMsg, "en"); !ok || r != "" {
|
||||||
|
t.Errorf("allow + off rail = %v/%q, want true/empty (bypasses the ops switch)", ok, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverrideDeny blocks even when the rail is on, with a non-empty reason.
|
||||||
|
if ok, r := PurchaseGate(OverrideDeny, on, "en"); ok || r == "" {
|
||||||
|
t.Errorf("deny + on rail = %v/%q, want false + a reason", ok, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The message falls back to the other language when only one is set.
|
||||||
|
if _, r := PurchaseGate(OverrideDefault, RailAvailability{MessageEN: "OnlyEN"}, "ru"); r != "OnlyEN" {
|
||||||
|
t.Errorf("off rail ru with only EN msg = %q, want OnlyEN (fallback)", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,11 @@ const atomChips = "chips"
|
|||||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||||
// no method, so only values show; buying is gated server-side regardless.
|
// no method, so only values show; buying is gated server-side regardless.
|
||||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||||
|
// Present products in the canonical listing order shared with the offer and the admin console
|
||||||
|
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||||
|
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||||
|
// it does not reuse.
|
||||||
|
sortCatalogEntries(entries)
|
||||||
var out CatalogView
|
var out CatalogView
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
isPack := false
|
isPack := false
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -141,10 +143,60 @@ func validateProduct(in ProductInput, sellable bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||||
return s.store.adminCatalog(ctx)
|
// the catalog tables.
|
||||||
|
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
return s.store.adminCatalog(ctx, includeInactive)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
|
||||||
@@ -153,7 +205,11 @@ func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active boo
|
|||||||
if err := validateProduct(in, active); err != nil {
|
if err := validateProduct(in, active); err != nil {
|
||||||
return uuid.Nil, err
|
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
|
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
|
||||||
@@ -166,7 +222,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
|
|||||||
if err := validateProduct(in, active); err != nil {
|
if err := validateProduct(in, active); err != nil {
|
||||||
return err
|
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
|
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
|
||||||
@@ -181,11 +241,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
|
|||||||
return err
|
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
|
// 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.
|
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
|
||||||
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||||
|
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||||
|
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||||
|
// row sorts last rather than leading).
|
||||||
|
type catalogRank struct {
|
||||||
|
pack bool
|
||||||
|
group int
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||||
|
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||||
|
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||||
|
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||||
|
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||||
|
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||||
|
func compareCatalogRank(a, b catalogRank) int {
|
||||||
|
if a.pack != b.pack {
|
||||||
|
if a.pack {
|
||||||
|
return -1 // packs (sales) before values (chip exchange)
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !a.pack {
|
||||||
|
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.amount, b.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||||
|
func entryRank(e catalogEntry) catalogRank {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adminRank builds the canonical rank of an admin catalog product.
|
||||||
|
func adminRank(p AdminProduct) catalogRank {
|
||||||
|
if adminIsPack(p) {
|
||||||
|
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||||
|
}
|
||||||
|
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||||
|
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||||
|
func sortCatalogEntries(entries []catalogEntry) {
|
||||||
|
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||||
|
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -134,3 +134,97 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
|||||||
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||||
|
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||||
|
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||||
|
// console, regardless of catalog creation order.
|
||||||
|
func TestProjectCatalog_CanonicalOrder(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: "direct", 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 shuffled on input; the projection must impose the canonical order.
|
||||||
|
entries := []catalogEntry{
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||||
|
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||||
|
if len(got.Products) != len(want) {
|
||||||
|
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||||
|
}
|
||||||
|
for i, p := range got.Products {
|
||||||
|
if p.Title != want[i] {
|
||||||
|
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// productTitles extracts the projected product titles for a failure message.
|
||||||
|
func productTitles(products []CatalogProduct) []string {
|
||||||
|
out := make([]string, len(products))
|
||||||
|
for i, p := range products {
|
||||||
|
out[i] = p.Title
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,210 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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 {
|
||||||
|
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||||
|
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||||
|
// caller's slice is left untouched.
|
||||||
|
sorted := slices.Clone(entries)
|
||||||
|
sortCatalogEntries(sorted)
|
||||||
|
var packs, values []catalogEntry
|
||||||
|
for _, e := range sorted {
|
||||||
|
if isPackEntry(e) {
|
||||||
|
packs = append(packs, e)
|
||||||
|
} else {
|
||||||
|
values = append(values, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -19,6 +20,13 @@ import (
|
|||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
clock func() time.Time
|
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.
|
// NewService constructs a Service over store with a wall-clock time source.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CanPurchase reports whether an account may open a purchase order on rail, combining the account's
|
||||||
|
// per-account override with the rail's operational switch (PurchaseGate). It is the operational
|
||||||
|
// availability layer only — the caller still enforces the security gates (trusted platform, the D36
|
||||||
|
// email anchor, the VK-iOS spend freeze, the min client version). On a block, reason is localized to
|
||||||
|
// lang for the user; err is only a store failure.
|
||||||
|
func (s *Service) CanPurchase(ctx context.Context, accountID uuid.UUID, rail, lang string) (ok bool, reason string, err error) {
|
||||||
|
ov, err := s.store.purchaseOverride(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
avail, err := s.store.railAvailability(ctx, rail)
|
||||||
|
if err != nil {
|
||||||
|
return false, "", err
|
||||||
|
}
|
||||||
|
ok, reason = PurchaseGate(ov, avail, lang)
|
||||||
|
return ok, reason, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RailStatuses returns every known rail's operational status for the admin editor, filling a rail
|
||||||
|
// with no stored row with the fail-open enabled default so the editor always shows one row per rail.
|
||||||
|
func (s *Service) RailStatuses(ctx context.Context) (map[string]RailAvailability, error) {
|
||||||
|
stored, err := s.store.allRailStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[string]RailAvailability, len(KnownRails))
|
||||||
|
for _, rail := range KnownRails {
|
||||||
|
if a, ok := stored[rail]; ok {
|
||||||
|
out[rail] = a
|
||||||
|
} else {
|
||||||
|
out[rail] = RailAvailability{Enabled: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRailStatus upserts a rail's operational status from the admin editor. It rejects an unknown rail
|
||||||
|
// key so a typo cannot create a dead row.
|
||||||
|
func (s *Service) SetRailStatus(ctx context.Context, rail string, a RailAvailability) error {
|
||||||
|
if !isKnownRail(rail) {
|
||||||
|
return fmt.Errorf("payments: unknown rail %q", rail)
|
||||||
|
}
|
||||||
|
return s.store.setRailStatus(ctx, rail, a, s.clock())
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurchaseOverrideFor returns an account's purchase override (OverrideDefault when none is set).
|
||||||
|
func (s *Service) PurchaseOverrideFor(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
|
||||||
|
return s.store.purchaseOverride(ctx, accountID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPurchaseOverride sets or clears an account's purchase override (OverrideDefault deletes the row).
|
||||||
|
func (s *Service) SetPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride) error {
|
||||||
|
return s.store.setPurchaseOverride(ctx, accountID, ov, s.clock())
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
|
|||||||
amount: pack.price,
|
amount: pack.price,
|
||||||
origin: method,
|
origin: method,
|
||||||
provider: provider,
|
provider: provider,
|
||||||
|
shop: directShop(cxt),
|
||||||
}
|
}
|
||||||
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
|
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
|
||||||
return OrderResult{}, err
|
return OrderResult{}, err
|
||||||
@@ -53,6 +54,16 @@ func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Cont
|
|||||||
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
|
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// directShop returns the merchant shop (channel) a direct order is issued through — the trusted
|
||||||
|
// platform subtype for the direct rail ("web"/"android"), or "" for any other rail (the per-shop
|
||||||
|
// split is direct-only; D42/D44). Recorded on the order for the admin per-channel breakdown.
|
||||||
|
func directShop(cxt Context) string {
|
||||||
|
if cxt.Kind == SourceDirect {
|
||||||
|
return cxt.Subtype
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
|
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
|
||||||
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
|
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
|
||||||
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
|
||||||
@@ -157,6 +168,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
|||||||
return payout, err
|
return payout, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
||||||
|
// the per-day and per-hour caps.
|
||||||
|
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||||
|
return s.store.rewardConfig(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
||||||
|
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
||||||
|
// offer stale.
|
||||||
|
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
||||||
|
return errors.New("payments: reward payout and caps must be non-negative")
|
||||||
|
}
|
||||||
|
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
||||||
|
}
|
||||||
|
|
||||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ type RiskInfo struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
|
// LedgerEntry is one append-only ledger row projected for the report. The string ids are empty when
|
||||||
// the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
|
// the column is NULL; Shop is the direct-rail merchant channel the referenced order used (empty for
|
||||||
|
// other rails or order-less rows); Snapshot is the raw purchase/refund JSON (empty when none).
|
||||||
type LedgerEntry struct {
|
type LedgerEntry struct {
|
||||||
Kind string
|
Kind string
|
||||||
Source string
|
Source string
|
||||||
@@ -52,6 +53,7 @@ type LedgerEntry struct {
|
|||||||
OrderID string
|
OrderID string
|
||||||
Provider string
|
Provider string
|
||||||
ProviderPaymentID string
|
ProviderPaymentID string
|
||||||
|
Shop string
|
||||||
Snapshot string
|
Snapshot string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package payments
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// railAvailability reads a rail's operational availability. A missing row is fail-open: enabled with
|
||||||
|
// no message, so payments stay on unless an operator explicitly disabled the rail.
|
||||||
|
func (s *Store) railAvailability(ctx context.Context, rail string) (RailAvailability, error) {
|
||||||
|
var a RailAvailability
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT enabled, message_ru, message_en FROM payments.rail_status WHERE rail = $1`, rail).
|
||||||
|
Scan(&a.Enabled, &a.MessageRU, &a.MessageEN)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return RailAvailability{Enabled: true}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return RailAvailability{}, fmt.Errorf("payments: read rail status %q: %w", rail, err)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allRailStatus reads every stored rail-status row for the admin editor, keyed by rail. Rails with
|
||||||
|
// no row are absent (the caller fills them with the fail-open enabled default).
|
||||||
|
func (s *Store) allRailStatus(ctx context.Context) (map[string]RailAvailability, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT rail, enabled, message_ru, message_en FROM payments.rail_status`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: read rail statuses: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[string]RailAvailability)
|
||||||
|
for rows.Next() {
|
||||||
|
var rail string
|
||||||
|
var a RailAvailability
|
||||||
|
if err := rows.Scan(&rail, &a.Enabled, &a.MessageRU, &a.MessageEN); err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: scan rail status: %w", err)
|
||||||
|
}
|
||||||
|
out[rail] = a
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRailStatus upserts a rail's operational status (admin).
|
||||||
|
func (s *Store) setRailStatus(ctx context.Context, rail string, a RailAvailability, now time.Time) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO payments.rail_status (rail, enabled, message_ru, message_en, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (rail) DO UPDATE SET enabled = $2, message_ru = $3, message_en = $4, updated_at = $5`,
|
||||||
|
rail, a.Enabled, a.MessageRU, a.MessageEN, now); err != nil {
|
||||||
|
return fmt.Errorf("payments: set rail status %q: %w", rail, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// purchaseOverride reads an account's purchase override; a missing row is OverrideDefault.
|
||||||
|
func (s *Store) purchaseOverride(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
|
||||||
|
var allow bool
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT allow FROM payments.account_payment_override WHERE account_id = $1`, accountID).Scan(&allow)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return OverrideDefault, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return OverrideDefault, fmt.Errorf("payments: read purchase override %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
if allow {
|
||||||
|
return OverrideAllow, nil
|
||||||
|
}
|
||||||
|
return OverrideDeny, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setPurchaseOverride upserts an account's override, or deletes the row for OverrideDefault (the
|
||||||
|
// default state is the absence of a row).
|
||||||
|
func (s *Store) setPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride, now time.Time) error {
|
||||||
|
if ov == OverrideDefault {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`DELETE FROM payments.account_payment_override WHERE account_id = $1`, accountID); err != nil {
|
||||||
|
return fmt.Errorf("payments: clear purchase override %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO payments.account_payment_override (account_id, allow, updated_at) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (account_id) DO UPDATE SET allow = $2, updated_at = $3`,
|
||||||
|
accountID, ov == OverrideAllow, now); err != nil {
|
||||||
|
return fmt.Errorf("payments: set purchase override %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -19,11 +19,17 @@ import (
|
|||||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||||
|
|
||||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
// archived products to the active ones; with it false only active products are returned.
|
||||||
|
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||||
|
where := table.Product.Active.IS_TRUE()
|
||||||
|
if includeInactive {
|
||||||
|
where = postgres.Bool(true)
|
||||||
|
}
|
||||||
var prods []model.Product
|
var prods []model.Product
|
||||||
if err := postgres.SELECT(table.Product.AllColumns).
|
if err := postgres.SELECT(table.Product.AllColumns).
|
||||||
FROM(table.Product).
|
FROM(table.Product).
|
||||||
|
WHERE(where).
|
||||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||||
QueryContext(ctx, s.db, &prods); err != nil {
|
QueryContext(ctx, s.db, &prods); err != nil {
|
||||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ type newOrder struct {
|
|||||||
amount Money
|
amount Money
|
||||||
origin Source
|
origin Source
|
||||||
provider string
|
provider string
|
||||||
|
shop string
|
||||||
}
|
}
|
||||||
|
|
||||||
// createOrder inserts a pending order.
|
// createOrder inserts a pending order.
|
||||||
@@ -159,12 +160,12 @@ func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) erro
|
|||||||
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
|
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
|
||||||
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
|
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
|
||||||
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
|
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
|
||||||
table.Orders.CreatedAt, table.Orders.UpdatedAt,
|
table.Orders.CreatedAt, table.Orders.UpdatedAt, table.Orders.Shop,
|
||||||
).VALUES(
|
).VALUES(
|
||||||
o.orderID, o.accountID, o.platform,
|
o.orderID, o.accountID, o.platform,
|
||||||
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
o.productID, o.amount.Minor(), string(o.amount.Currency()),
|
||||||
string(o.origin), "pending", o.provider,
|
string(o.origin), "pending", o.provider,
|
||||||
now, now,
|
now, now, o.shop,
|
||||||
)
|
)
|
||||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||||
return fmt.Errorf("payments: create order: %w", err)
|
return fmt.Errorf("payments: create order: %w", err)
|
||||||
@@ -413,6 +414,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
|
|||||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
|
||||||
|
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
|
||||||
|
// and also enforced by the config CHECK constraints.
|
||||||
|
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||||
|
if _, err := s.db.ExecContext(ctx,
|
||||||
|
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||||
|
payout, dailyCap, hourlyCap); err != nil {
|
||||||
|
return fmt.Errorf("payments: set reward config: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||||
|
|||||||
@@ -74,9 +74,39 @@ func (s *Store) accountStatement(ctx context.Context, accountID uuid.UUID) (Stat
|
|||||||
CreatedAt: r.CreatedAt,
|
CreatedAt: r.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Annotate direct-rail entries with the merchant shop (channel) their order was issued through
|
||||||
|
// (E10/D44), keyed by the ledger's order id. Non-direct / order-less entries stay "".
|
||||||
|
shops, err := s.orderShops(ctx, accountID)
|
||||||
|
if err != nil {
|
||||||
|
return Statement{}, err
|
||||||
|
}
|
||||||
|
for i := range out.Ledger {
|
||||||
|
out.Ledger[i].Shop = shops[out.Ledger[i].OrderID]
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// orderShops maps an account's order ids (as strings) to the merchant shop (channel) each direct
|
||||||
|
// order was issued through, for annotating the ledger report (E10/D44). Orders with an empty shop
|
||||||
|
// (non-direct or pre-split) are omitted, so a lookup miss yields "".
|
||||||
|
func (s *Store) orderShops(ctx context.Context, accountID uuid.UUID) (map[string]string, error) {
|
||||||
|
var rows []model.Orders
|
||||||
|
if err := postgres.SELECT(table.Orders.OrderID, table.Orders.Shop).
|
||||||
|
FROM(table.Orders).
|
||||||
|
WHERE(table.Orders.AccountID.EQ(postgres.UUID(accountID))).
|
||||||
|
QueryContext(ctx, s.db, &rows); err != nil {
|
||||||
|
return nil, fmt.Errorf("payments: load order shops %s: %w", accountID, err)
|
||||||
|
}
|
||||||
|
m := make(map[string]string, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.Shop != "" {
|
||||||
|
m[r.OrderID.String()] = r.Shop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
|
// allLedger reads the entire append-only ledger (all accounts, newest first) for the admin export.
|
||||||
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
func (s *Store) allLedger(ctx context.Context) ([]LedgerExportRow, error) {
|
||||||
var rows []model.Ledger
|
var rows []model.Ledger
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ type Orders struct {
|
|||||||
ProviderPaymentID *string
|
ProviderPaymentID *string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
Shop string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type ordersTable struct {
|
|||||||
ProviderPaymentID postgres.ColumnString
|
ProviderPaymentID postgres.ColumnString
|
||||||
CreatedAt postgres.ColumnTimestampz
|
CreatedAt postgres.ColumnTimestampz
|
||||||
UpdatedAt postgres.ColumnTimestampz
|
UpdatedAt postgres.ColumnTimestampz
|
||||||
|
Shop postgres.ColumnString
|
||||||
|
|
||||||
AllColumns postgres.ColumnList
|
AllColumns postgres.ColumnList
|
||||||
MutableColumns postgres.ColumnList
|
MutableColumns postgres.ColumnList
|
||||||
@@ -82,9 +83,10 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
|||||||
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
ProviderPaymentIDColumn = postgres.StringColumn("provider_payment_id")
|
||||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||||
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
ShopColumn = postgres.StringColumn("shop")
|
||||||
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
|
allColumns = postgres.ColumnList{OrderIDColumn, AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||||
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||||
|
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
|
||||||
)
|
)
|
||||||
|
|
||||||
return ordersTable{
|
return ordersTable{
|
||||||
@@ -103,6 +105,7 @@ func newOrdersTableImpl(schemaName, tableName, alias string) ordersTable {
|
|||||||
ProviderPaymentID: ProviderPaymentIDColumn,
|
ProviderPaymentID: ProviderPaymentIDColumn,
|
||||||
CreatedAt: CreatedAtColumn,
|
CreatedAt: CreatedAtColumn,
|
||||||
UpdatedAt: UpdatedAtColumn,
|
UpdatedAt: UpdatedAtColumn,
|
||||||
|
Shop: ShopColumn,
|
||||||
|
|
||||||
AllColumns: allColumns,
|
AllColumns: allColumns,
|
||||||
MutableColumns: mutableColumns,
|
MutableColumns: mutableColumns,
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Multi-shop direct rail (E10/D44): record which Robokassa merchant shop (channel) issued a direct
|
||||||
|
-- order — "web" / "android" (ios later) — so the admin financial report can break direct payments
|
||||||
|
-- down by channel. Only the direct rail is per-channel; other rails leave it "". Additive only (a
|
||||||
|
-- defaulted column), applies forward via goose with no data rewrite (no contour wipe); an image
|
||||||
|
-- rollback ignores the column.
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
ALTER TABLE payments.orders
|
||||||
|
ADD COLUMN shop text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
ALTER TABLE payments.orders DROP COLUMN shop;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Payment availability controls (D45/D46): a per-rail operational kill switch with a localized message,
|
||||||
|
-- and a per-account purchase override. Both let an operator disable payments live from the admin —
|
||||||
|
-- a whole rail/channel, or one account — and explain why to the user on a purchase attempt. Additive
|
||||||
|
-- (new tables) — applies forward via goose with no data rewrite (no contour wipe); an image rollback
|
||||||
|
-- ignores both tables. Fail-open by design: a rail with no row is enabled; an account with no row
|
||||||
|
-- follows the rail switch.
|
||||||
|
-- +goose Up
|
||||||
|
|
||||||
|
CREATE TABLE payments.rail_status (
|
||||||
|
rail text PRIMARY KEY,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
message_ru text NOT NULL DEFAULT '',
|
||||||
|
message_en text NOT NULL DEFAULT '',
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE payments.account_payment_override (
|
||||||
|
account_id uuid PRIMARY KEY,
|
||||||
|
allow boolean NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
|
||||||
|
DROP TABLE payments.account_payment_override;
|
||||||
|
DROP TABLE payments.rail_status;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package robokassa
|
||||||
|
|
||||||
|
// Shops is a set of Robokassa merchant shops keyed by channel — the subtype of the trusted
|
||||||
|
// X-Platform signal for the direct rail ("web", "android"; "ios" later). The direct rail routes a
|
||||||
|
// payment to the per-channel shop for separate merchant accounts, accounting and receipts, while
|
||||||
|
// every shop still credits the one direct wallet (docs/PAYMENTS_DECISIONS_ru.md D42). An empty set
|
||||||
|
// leaves the direct order and Result-callback endpoints unregistered.
|
||||||
|
type Shops map[string]Config
|
||||||
|
|
||||||
|
// Channel constants name the direct-rail X-Platform subtypes a shop is keyed by. ChannelWeb is the
|
||||||
|
// default: an unknown or empty channel routes here on the order side, and the legacy single-shop
|
||||||
|
// configuration seeds it.
|
||||||
|
const (
|
||||||
|
ChannelWeb = "web"
|
||||||
|
ChannelAndroid = "android"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Shop returns the shop that issues an order for channel, falling back to the web shop when channel
|
||||||
|
// is unknown, empty or not configured. The fallback is safe: routing only chooses the merchant
|
||||||
|
// account and receipt, never the credited wallet (always direct, D42), so a mis-attributed channel
|
||||||
|
// costs at most accounting accuracy, not money. The second result is false when neither the channel
|
||||||
|
// nor the web shop has a merchant login (the rail is unconfigured).
|
||||||
|
func (s Shops) Shop(channel string) (Config, bool) {
|
||||||
|
if channel != "" {
|
||||||
|
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c, ok := s[ChannelWeb]; ok && c.MerchantLogin != "" {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
return Config{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verifier returns the shop whose Password2 verifies a Result callback delivered to channel's
|
||||||
|
// dedicated Result route. Unlike Shop it does not fall back to web: each shop's callback is verified
|
||||||
|
// only by that shop's own credentials, so a route with no configured shop reports false (and its
|
||||||
|
// handler answers as unregistered). The second result is false when channel has no merchant login.
|
||||||
|
func (s Shops) Verifier(channel string) (Config, bool) {
|
||||||
|
if c, ok := s[channel]; ok && c.MerchantLogin != "" {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
return Config{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured reports whether at least one shop has a merchant login (the direct rail is live).
|
||||||
|
func (s Shops) Configured() bool {
|
||||||
|
for _, c := range s {
|
||||||
|
if c.MerchantLogin != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package robokassa
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShopsShopRouting(t *testing.T) {
|
||||||
|
shops := Shops{
|
||||||
|
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
|
||||||
|
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
|
||||||
|
}
|
||||||
|
// Order side: the exact channel wins.
|
||||||
|
if c, ok := shops.Shop(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
|
||||||
|
t.Errorf("Shop(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
|
||||||
|
}
|
||||||
|
// Order side: an unknown or empty channel falls back to the web shop (safe — always credits direct).
|
||||||
|
if c, ok := shops.Shop("ios"); !ok || c.MerchantLogin != "webshop" {
|
||||||
|
t.Errorf("Shop(ios) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
|
||||||
|
}
|
||||||
|
if c, ok := shops.Shop(""); !ok || c.MerchantLogin != "webshop" {
|
||||||
|
t.Errorf("Shop(empty) = %q/%v, want webshop/true (web fallback)", c.MerchantLogin, ok)
|
||||||
|
}
|
||||||
|
// No web shop and an unknown channel → unconfigured.
|
||||||
|
if _, ok := (Shops{ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"}}).Shop("ios"); ok {
|
||||||
|
t.Error("Shop(ios) with no web shop returned ok, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopsVerifierIsStrict(t *testing.T) {
|
||||||
|
shops := Shops{
|
||||||
|
ChannelWeb: {MerchantLogin: "webshop", Password1: "w1", Password2: "w2"},
|
||||||
|
ChannelAndroid: {MerchantLogin: "androidshop", Password1: "a1", Password2: "a2"},
|
||||||
|
}
|
||||||
|
// Callback side: the exact channel's own credentials, no web fallback (each callback is verified
|
||||||
|
// only by its own shop's Password2).
|
||||||
|
if c, ok := shops.Verifier(ChannelAndroid); !ok || c.MerchantLogin != "androidshop" {
|
||||||
|
t.Errorf("Verifier(android) = %q/%v, want androidshop/true", c.MerchantLogin, ok)
|
||||||
|
}
|
||||||
|
if _, ok := shops.Verifier("ios"); ok {
|
||||||
|
t.Error("Verifier(ios) returned ok, want false (no fallback)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShopsConfigured(t *testing.T) {
|
||||||
|
if (Shops{}).Configured() {
|
||||||
|
t.Error("an empty set reported configured")
|
||||||
|
}
|
||||||
|
if (Shops{ChannelWeb: {}}).Configured() {
|
||||||
|
t.Error("a shop with no merchant login reported configured")
|
||||||
|
}
|
||||||
|
if !(Shops{ChannelWeb: {MerchantLogin: "s", Password1: "1", Password2: "2"}}).Configured() {
|
||||||
|
t.Error("a configured shop reported not configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,9 @@ func (s *Server) registerRoutes() {
|
|||||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||||
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
||||||
u.POST("/wallet/reward", s.handleWalletReward)
|
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 {
|
if s.payments != nil {
|
||||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||||
@@ -86,7 +89,7 @@ func (s *Server) registerRoutes() {
|
|||||||
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
|
||||||
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
|
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
|
||||||
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
|
||||||
if s.robokassa.MerchantLogin != "" {
|
if s.robokassa.Configured() {
|
||||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,21 +33,71 @@ var priceFields = []struct {
|
|||||||
{"price_chip", "", payments.CurrencyChip},
|
{"price_chip", "", payments.CurrencyChip},
|
||||||
}
|
}
|
||||||
|
|
||||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||||
// flag, and the inline create form.
|
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||||
|
// active/all toggle).
|
||||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
showAll := c.Query("all") == "1"
|
||||||
|
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var view adminconsole.CatalogView
|
// 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)
|
||||||
|
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rails, err := s.payments.RailStatuses(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.consoleError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||||
|
for _, rail := range payments.KnownRails {
|
||||||
|
a := rails[rail]
|
||||||
|
view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN})
|
||||||
|
}
|
||||||
for _, p := range products {
|
for _, p := range products {
|
||||||
view.Products = append(view.Products, catalogRow(p))
|
view.Products = append(view.Products, catalogRow(p))
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||||||
|
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||||||
|
// refused by the service.
|
||||||
|
func (s *Server) consoleSetReward(c *gin.Context) {
|
||||||
|
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||||||
|
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||||||
|
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||||||
|
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleSetRailStatus toggles a payment rail's operational kill switch and its user-facing
|
||||||
|
// off-message (per language) from the catalog page's payment-availability form. An unchecked box
|
||||||
|
// disables the rail; an unknown rail is refused by the service.
|
||||||
|
func (s *Server) consoleSetRailStatus(c *gin.Context) {
|
||||||
|
rail := strings.TrimSpace(c.PostForm("rail"))
|
||||||
|
a := payments.RailAvailability{
|
||||||
|
Enabled: c.PostForm("enabled") == "on",
|
||||||
|
MessageRU: strings.TrimSpace(c.PostForm("message_ru")),
|
||||||
|
MessageEN: strings.TrimSpace(c.PostForm("message_en")),
|
||||||
|
}
|
||||||
|
if err := s.payments.SetRailStatus(c.Request.Context(), rail, a); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "payment availability was updated", catalogBack)
|
||||||
|
}
|
||||||
|
|
||||||
// catalogRow projects a product into its list row.
|
// catalogRow projects a product into its list row.
|
||||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||||
@@ -66,7 +116,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.consoleError(c, err)
|
s.consoleError(c, err)
|
||||||
return
|
return
|
||||||
@@ -239,7 +289,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
|||||||
// bundles, including archived ones — chips and tournament products are excluded).
|
// bundles, including archived ones — chips and tournament products are excluded).
|
||||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||||
products, err := s.payments.AdminCatalog(ctx)
|
products, err := s.payments.AdminCatalog(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
gm.POST("/users/:id/grant", s.consoleGrant)
|
gm.POST("/users/:id/grant", s.consoleGrant)
|
||||||
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
|
||||||
gm.POST("/users/:id/refund", s.consoleRefund)
|
gm.POST("/users/:id/refund", s.consoleRefund)
|
||||||
|
gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride)
|
||||||
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
gm.POST("/users/:id/delete", s.consoleDeleteUser)
|
||||||
gm.GET("/reasons", s.consoleReasons)
|
gm.GET("/reasons", s.consoleReasons)
|
||||||
gm.POST("/reasons", s.consoleCreateReason)
|
gm.POST("/reasons", s.consoleCreateReason)
|
||||||
@@ -112,6 +113,8 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
if s.payments != nil {
|
if s.payments != nil {
|
||||||
gm.GET("/catalog", s.consoleCatalog)
|
gm.GET("/catalog", s.consoleCatalog)
|
||||||
gm.POST("/catalog", s.consoleCreateProduct)
|
gm.POST("/catalog", s.consoleCreateProduct)
|
||||||
|
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||||
|
gm.POST("/catalog/rail-status", s.consoleSetRailStatus)
|
||||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||||
@@ -460,6 +463,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
|||||||
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
|
||||||
}
|
}
|
||||||
view.Grant = s.grantForm(ctx)
|
view.Grant = s.grantForm(ctx)
|
||||||
|
if ov, oerr := s.payments.PurchaseOverrideFor(ctx, id); oerr == nil {
|
||||||
|
view.PurchaseOverride = overrideName(ov)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
s.renderConsole(c, "user_detail", "users", acc.DisplayName, view)
|
||||||
}
|
}
|
||||||
@@ -481,13 +487,54 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
|
|||||||
for _, e := range stmt.Ledger {
|
for _, e := range stmt.Ledger {
|
||||||
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
fv.Ledger = append(fv.Ledger, adminconsole.LedgerRow{
|
||||||
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
Kind: e.Kind, Source: e.Source, Origin: e.Origin, ChipsDelta: e.ChipsDelta,
|
||||||
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Snapshot: e.Snapshot,
|
Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, Shop: e.Shop, Snapshot: e.Snapshot,
|
||||||
At: fmtTime(e.CreatedAt),
|
At: fmtTime(e.CreatedAt),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return fv
|
return fv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// overrideName renders a purchase override as the form/select value ("default"/"allow"/"deny").
|
||||||
|
func overrideName(ov payments.PurchaseOverride) string {
|
||||||
|
switch ov {
|
||||||
|
case payments.OverrideAllow:
|
||||||
|
return "allow"
|
||||||
|
case payments.OverrideDeny:
|
||||||
|
return "deny"
|
||||||
|
default:
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// consoleSetPurchaseOverride sets or clears an account's per-account purchase override (allow / deny
|
||||||
|
// / default) from the user card. "allow" bypasses only the operational rail switch, never the
|
||||||
|
// security gates; "default" clears the override (deletes the row).
|
||||||
|
func (s *Server) consoleSetPurchaseOverride(c *gin.Context) {
|
||||||
|
id, ok := s.consoleUUID(c, "/_gm/users")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
back := "/_gm/users/" + id.String()
|
||||||
|
if s.payments == nil {
|
||||||
|
s.renderConsoleMessage(c, "Unavailable", "payments are not configured", back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var ov payments.PurchaseOverride
|
||||||
|
switch c.PostForm("override") {
|
||||||
|
case "allow":
|
||||||
|
ov = payments.OverrideAllow
|
||||||
|
case "deny":
|
||||||
|
ov = payments.OverrideDeny
|
||||||
|
default:
|
||||||
|
ov = payments.OverrideDefault
|
||||||
|
}
|
||||||
|
if err := s.payments.SetPurchaseOverride(c.Request.Context(), id, ov); err != nil {
|
||||||
|
s.renderConsoleMessage(c, "Update failed", err.Error(), back)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.renderConsoleMessage(c, "Saved", "the purchase override was updated", back)
|
||||||
|
}
|
||||||
|
|
||||||
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
|
// relationRows maps the social graph entries to the cross-linked, date-formatted rows the
|
||||||
// user card renders.
|
// user card renders.
|
||||||
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
|
||||||
@@ -566,6 +613,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
|
|||||||
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
|
||||||
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
|
||||||
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
|
||||||
|
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
|
||||||
}
|
}
|
||||||
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
|
||||||
// anchors the robot's sleep window for the next-move ETA.
|
// anchors the robot's sleep window for the next-move ETA.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"scrabble/backend/internal/payments"
|
"scrabble/backend/internal/payments"
|
||||||
|
"scrabble/backend/internal/robokassa"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Ledger/order provider tags per rail.
|
// Ledger/order provider tags per rail.
|
||||||
@@ -66,9 +67,25 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
|||||||
s.abortErr(c, err)
|
s.abortErr(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Operational availability gate (D45/D46): the per-rail kill switch + the per-account override,
|
||||||
|
// before any order is opened. Orthogonal to the security gates in the rail branches below — a
|
||||||
|
// per-account "allow" override bypasses only this switch, never those. The reason is localized to
|
||||||
|
// the account's language for the user.
|
||||||
|
lang := ""
|
||||||
|
if acc, aerr := s.accounts.GetByID(ctx, uid); aerr == nil {
|
||||||
|
lang = acc.PreferredLanguage
|
||||||
|
}
|
||||||
|
if ok, reason, aerr := s.payments.CanPurchase(ctx, uid, payments.RailKey(cxt.Kind, cxt.Subtype), lang); aerr != nil {
|
||||||
|
s.abortErr(c, aerr)
|
||||||
|
return
|
||||||
|
} else if !ok {
|
||||||
|
c.AbortWithStatusJSON(http.StatusServiceUnavailable, errorResponse{Error: errorBody{Code: "payment_unavailable", Message: reason}})
|
||||||
|
return
|
||||||
|
}
|
||||||
switch cxt.Kind {
|
switch cxt.Kind {
|
||||||
case payments.SourceDirect:
|
case payments.SourceDirect:
|
||||||
if s.robokassa.MerchantLogin == "" {
|
shop, ok := s.robokassa.Shop(cxt.Subtype)
|
||||||
|
if !ok {
|
||||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -89,7 +106,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, walletOrderResponse{
|
c.JSON(http.StatusOK, walletOrderResponse{
|
||||||
OrderID: res.OrderID.String(),
|
OrderID: res.OrderID.String(),
|
||||||
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
RedirectURL: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||||
Rail: providerRobokassa,
|
Rail: providerRobokassa,
|
||||||
})
|
})
|
||||||
case payments.SourceVK:
|
case payments.SourceVK:
|
||||||
@@ -136,7 +153,16 @@ func (s *Server) handleRobokassaResult(c *gin.Context) {
|
|||||||
for k, val := range params {
|
for k, val := range params {
|
||||||
v.Set(k, val)
|
v.Set(k, val)
|
||||||
}
|
}
|
||||||
orderID, outSum, ok := s.robokassa.VerifyResult(v)
|
// The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to
|
||||||
|
// the web shop. Each channel is verified only by its own shop's Password2 (Verifier is strict).
|
||||||
|
channel := c.DefaultQuery("channel", robokassa.ChannelWeb)
|
||||||
|
shop, ok := s.robokassa.Verifier(channel)
|
||||||
|
if !ok {
|
||||||
|
s.log.Warn("robokassa result: unknown shop channel", zap.String("channel", channel))
|
||||||
|
c.String(http.StatusBadRequest, "bad sign")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
orderID, outSum, ok := shop.VerifyResult(v)
|
||||||
if !ok {
|
if !ok {
|
||||||
s.log.Warn("robokassa result: bad signature")
|
s.log.Warn("robokassa result: bad signature")
|
||||||
c.String(http.StatusBadRequest, "bad sign")
|
c.String(http.StatusBadRequest, "bad sign")
|
||||||
|
|||||||
@@ -112,6 +112,22 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, catalogDTOFrom(view))
|
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
|
// 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
|
// 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.
|
// unconfigured), which gates the client's "watch for chips" button.
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ type Deps struct {
|
|||||||
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
// Renderer is the image-render sidecar client for the PNG export artifact. A
|
||||||
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
|
||||||
Renderer *render.Client
|
Renderer *render.Client
|
||||||
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
|
// Robokassa configures the direct-rail (RUB) provider — one merchant shop per channel; an empty
|
||||||
// order and Result-callback endpoints unregistered.
|
// set leaves the order and Result-callback endpoints unregistered.
|
||||||
Robokassa robokassa.Config
|
Robokassa robokassa.Shops
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server owns the gin engine, the underlying HTTP server and the readiness
|
// Server owns the gin engine, the underlying HTTP server and the readiness
|
||||||
@@ -146,7 +146,7 @@ type Server struct {
|
|||||||
ads *ads.Service
|
ads *ads.Service
|
||||||
payments *payments.Service
|
payments *payments.Service
|
||||||
gamelimits *gamelimits.Service
|
gamelimits *gamelimits.Service
|
||||||
robokassa robokassa.Config
|
robokassa robokassa.Shops
|
||||||
notifier notify.Publisher
|
notifier notify.Publisher
|
||||||
console *adminconsole.Renderer
|
console *adminconsole.Renderer
|
||||||
exportKey []byte
|
exportKey []byte
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
|||||||
zap.String("path", route),
|
zap.String("path", route),
|
||||||
zap.Int("status", status),
|
zap.Int("status", status),
|
||||||
zap.Duration("latency", elapsed),
|
zap.Duration("latency", elapsed),
|
||||||
|
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
||||||
|
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
||||||
|
zap.String("client_ip", c.ClientIP()),
|
||||||
}
|
}
|
||||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
|||||||
|
|
||||||
# --- UI build args (baked into the gateway image) ---------------------------
|
# --- UI build args (baked into the gateway image) ---------------------------
|
||||||
VITE_TELEGRAM_BOT_ID=
|
VITE_TELEGRAM_BOT_ID=
|
||||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
|
||||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||||
@@ -115,6 +115,13 @@ TELEGRAM_MINIAPP_URL= # required; deploy derives it as PUBLIC_B
|
|||||||
TELEGRAM_TEST_ENV=false
|
TELEGRAM_TEST_ENV=false
|
||||||
TELEGRAM_API_BASE_URL=
|
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 ------------------------------------------------------------
|
# --- VK Mini App ------------------------------------------------------------
|
||||||
# The VK app's "protected key" (client_secret): the gateway verifies the 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).
|
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||||
@@ -135,6 +142,18 @@ ROBOKASSA_MERCHANT_LOGIN=
|
|||||||
ROBOKASSA_PASSWORD1=
|
ROBOKASSA_PASSWORD1=
|
||||||
ROBOKASSA_PASSWORD2=
|
ROBOKASSA_PASSWORD2=
|
||||||
ROBOKASSA_TEST=
|
ROBOKASSA_TEST=
|
||||||
|
# Multi-shop direct rail (D42): the vars above seed the "web" channel; add per-channel shops here.
|
||||||
|
# ROBOKASSA_WEB_* overrides the web shop; ROBOKASSA_ANDROID_* adds the android (RuStore) shop. Each
|
||||||
|
# credits the one direct wallet; an empty login drops that channel (routing falls back to web). The
|
||||||
|
# Robokassa cabinet points each shop's Result URL at /pay/robokassa/result/web and .../android.
|
||||||
|
ROBOKASSA_WEB_MERCHANT_LOGIN=
|
||||||
|
ROBOKASSA_WEB_PASSWORD1=
|
||||||
|
ROBOKASSA_WEB_PASSWORD2=
|
||||||
|
ROBOKASSA_WEB_TEST=
|
||||||
|
ROBOKASSA_ANDROID_MERCHANT_LOGIN=
|
||||||
|
ROBOKASSA_ANDROID_PASSWORD1=
|
||||||
|
ROBOKASSA_ANDROID_PASSWORD2=
|
||||||
|
ROBOKASSA_ANDROID_TEST=
|
||||||
|
|
||||||
# --- Gateway anti-abuse ------------------------------------------------------
|
# --- Gateway anti-abuse ------------------------------------------------------
|
||||||
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
# Planted honeytoken bearer value: any request presenting it is flagged — a 24h IP ban
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ compose binds from this directory.
|
|||||||
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
|
| `BACKEND_ROBOKASSA_MERCHANT_LOGIN` | secret | Robokassa shop login for the direct RUB rail. Empty leaves the rail off. |
|
||||||
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
|
| `BACKEND_ROBOKASSA_PASSWORD1` / `…_PASSWORD2` | secret | Robokassa pass phrases: Password1 signs the launch request, Password2 signs/verifies the Result callback. Use the shop's **test** pair while `…_ROBOKASSA_TEST=1`, the **live** pair for real money. (Password3 — Robokassa's JWT-invoice API — is unused.) |
|
||||||
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
|
| `BACKEND_ROBOKASSA_TEST` | **variable** | `1` runs test payments against the test pass phrases (no real money); empty/`0` is live. A variable, not a secret, so go-live is a flag flip + redeploy, not a secret rotation. |
|
||||||
|
| `BACKEND_ROBOKASSA_{WEB,ANDROID}_{MERCHANT_LOGIN,PASSWORD1,PASSWORD2,TEST}` | secret / variable | Multi-shop direct rail (D42): per-channel Robokassa shops. The legacy `BACKEND_ROBOKASSA_*` seeds the **web** shop; `…_WEB_*` overrides it, `…_ANDROID_*` adds the **android** (RuStore) shop. Each credits the one `direct` wallet; the cabinet Result URL per shop is `/pay/robokassa/result/{web,android}`. Empty login ⇒ that channel unconfigured (order routing falls back to the web shop). |
|
||||||
|
|
||||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||||
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
|
||||||
@@ -107,14 +108,19 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
|||||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||||
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
||||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
|
||||||
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||||
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||||
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||||
| `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_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_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_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). |
|
| `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_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). |
|
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
|
||||||
@@ -233,6 +239,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
|
**point-in-time recovery** (below) is the primary recovery path once armed, and the only one
|
||||||
that survives losing the host.
|
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. The build/release runbook follows.
|
||||||
|
|
||||||
|
- **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_RUSTORE_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
|
||||||
|
`ANDROID_RUSTORE_KEYSTORE_PASSWORD`, `ANDROID_RUSTORE_KEY_ALIAS`, `ANDROID_RUSTORE_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)
|
## Point-in-time recovery (PITR)
|
||||||
|
|
||||||
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
The main host archives Postgres continuously with **pgBackRest** to **Selectel S3**
|
||||||
@@ -255,8 +287,10 @@ shipping or redeploying this stack does **not** start archiving — the artifact
|
|||||||
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
|
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
|
||||||
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
|
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
|
||||||
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
|
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
|
||||||
and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both
|
and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
|
||||||
absent/NaN-safe, so they stay quiet until archiving is armed.
|
— the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
|
||||||
|
from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
|
||||||
|
archiving is armed.
|
||||||
|
|
||||||
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
|
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
|
||||||
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
|
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
|
||||||
|
|||||||
@@ -150,6 +150,13 @@
|
|||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
|
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
||||||
|
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
||||||
|
# services themselves run in UTC regardless; this is about host-side log correlation.
|
||||||
|
- name: Set the system timezone to UTC
|
||||||
|
community.general.timezone:
|
||||||
|
name: Etc/UTC
|
||||||
|
|
||||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||||
|
|||||||
@@ -107,6 +107,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The public legal pages are rendered by the render sidecar: the offer (with the live catalog
|
||||||
|
# price list from the backend spliced into ui/legal/offer_ru.md), the privacy policy and the EULA
|
||||||
|
# (both static markdown). Only these paths are exposed here — the sidecar's /render stays off this
|
||||||
|
# allow-list, internal-only. Kept disjoint from the landing/app paths so the catch-all below never
|
||||||
|
# shadows them.
|
||||||
|
@legal path /offer /offer/* /privacy /privacy/* /eula /eula/*
|
||||||
|
handle @legal {
|
||||||
|
reverse_proxy renderer:8090
|
||||||
|
}
|
||||||
|
|
||||||
# Everything else — the public landing at / and any stray path — is static.
|
# Everything else — the public landing at / and any stray path — is static.
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy landing:80
|
reverse_proxy landing:80
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
# docker compose -f docker-compose.bot.yml up -d
|
# 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
|
# 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
|
# published bot-link :9443 over mTLS. It exports no OTLP telemetry — otelcol lives on the
|
||||||
# main host and is unreachable from here — so observe it via `docker logs` on this host.
|
# 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
|
# 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.
|
# pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443.
|
||||||
name: scrabble-bot
|
name: scrabble-bot
|
||||||
@@ -50,7 +52,8 @@ services:
|
|||||||
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
|
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
|
||||||
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
|
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
|
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_TRACES_EXPORTER: none
|
||||||
TELEGRAM_OTEL_METRICS_EXPORTER: none
|
TELEGRAM_OTEL_METRICS_EXPORTER: none
|
||||||
GOMAXPROCS: "1"
|
GOMAXPROCS: "1"
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ services:
|
|||||||
logging: *default-logging
|
logging: *default-logging
|
||||||
environment:
|
environment:
|
||||||
RENDERER_PORT: "8090"
|
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:
|
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))"]
|
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
|
interval: 10s
|
||||||
@@ -168,6 +171,18 @@ services:
|
|||||||
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
|
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
|
||||||
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
|
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
|
||||||
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
|
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
|
||||||
|
# Per-channel shops for the multi-shop direct rail (D42): the legacy ROBOKASSA_* above seeds
|
||||||
|
# the "web" channel; ROBOKASSA_WEB_* overrides it and ROBOKASSA_ANDROID_* adds the android
|
||||||
|
# shop. Each shop credits the one direct wallet; an empty login drops that channel (order
|
||||||
|
# routing falls back to the web shop). Result URLs: /pay/robokassa/result/{web,android}.
|
||||||
|
BACKEND_ROBOKASSA_WEB_MERCHANT_LOGIN: ${ROBOKASSA_WEB_MERCHANT_LOGIN:-}
|
||||||
|
BACKEND_ROBOKASSA_WEB_PASSWORD1: ${ROBOKASSA_WEB_PASSWORD1:-}
|
||||||
|
BACKEND_ROBOKASSA_WEB_PASSWORD2: ${ROBOKASSA_WEB_PASSWORD2:-}
|
||||||
|
BACKEND_ROBOKASSA_WEB_TEST: ${ROBOKASSA_WEB_TEST:-}
|
||||||
|
BACKEND_ROBOKASSA_ANDROID_MERCHANT_LOGIN: ${ROBOKASSA_ANDROID_MERCHANT_LOGIN:-}
|
||||||
|
BACKEND_ROBOKASSA_ANDROID_PASSWORD1: ${ROBOKASSA_ANDROID_PASSWORD1:-}
|
||||||
|
BACKEND_ROBOKASSA_ANDROID_PASSWORD2: ${ROBOKASSA_ANDROID_PASSWORD2:-}
|
||||||
|
BACKEND_ROBOKASSA_ANDROID_TEST: ${ROBOKASSA_ANDROID_TEST:-}
|
||||||
# The dictionary lives on a named volume seeded from the image on first boot
|
# The dictionary lives on a named volume seeded from the image on first boot
|
||||||
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
|
||||||
# inherits). The admin console writes new version subdirectories here, and the
|
# inherits). The admin console writes new version subdirectories here, and the
|
||||||
@@ -219,6 +234,12 @@ services:
|
|||||||
GATEWAY_HTTP_ADDR: ":8081"
|
GATEWAY_HTTP_ADDR: ":8081"
|
||||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
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).
|
# Telegram auth validates against the home validator (plaintext, internal).
|
||||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||||
@@ -251,6 +272,12 @@ services:
|
|||||||
# secret; empty (unset secret) leaves the trap off.
|
# secret; empty (unset secret) leaves the trap off.
|
||||||
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
|
||||||
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
|
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_LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
GATEWAY_SERVICE_NAME: scrabble-gateway
|
GATEWAY_SERVICE_NAME: scrabble-gateway
|
||||||
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
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": [] },
|
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
|
||||||
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
"datasource": { "type": "prometheus", "uid": "prometheus" },
|
||||||
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
|
"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 }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
|
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
|
- orgId: 1
|
||||||
name: scrabble-host
|
name: scrabble-host
|
||||||
folder: Alerts
|
folder: Alerts
|
||||||
@@ -245,8 +271,14 @@ groups:
|
|||||||
conditions:
|
conditions:
|
||||||
- evaluator: { type: gt, params: [0] }
|
- evaluator: { type: gt, params: [0] }
|
||||||
labels: { severity: critical }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' }
|
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
|
||||||
|
|
||||||
|
# Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
|
||||||
|
# produced but not archived). A genuinely idle database archives nothing — Postgres does not
|
||||||
|
# force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
|
||||||
|
# during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
|
||||||
|
# label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
|
||||||
|
# increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
|
||||||
- uid: pg_archive_stalled
|
- uid: pg_archive_stalled
|
||||||
title: WAL archiving stalled
|
title: WAL archiving stalled
|
||||||
condition: C
|
condition: C
|
||||||
@@ -255,11 +287,11 @@ groups:
|
|||||||
execErrState: OK
|
execErrState: OK
|
||||||
data:
|
data:
|
||||||
- refId: A
|
- refId: A
|
||||||
relativeTimeRange: { from: 300, to: 0 }
|
relativeTimeRange: { from: 2100, to: 0 }
|
||||||
datasourceUid: prometheus
|
datasourceUid: prometheus
|
||||||
model:
|
model:
|
||||||
refId: A
|
refId: A
|
||||||
expr: pg_stat_archiver_last_archive_age
|
expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
|
||||||
instant: true
|
instant: true
|
||||||
- refId: C
|
- refId: C
|
||||||
datasourceUid: __expr__
|
datasourceUid: __expr__
|
||||||
@@ -270,4 +302,92 @@ groups:
|
|||||||
conditions:
|
conditions:
|
||||||
- evaluator: { type: gt, params: [1800] }
|
- evaluator: { type: gt, params: [1800] }
|
||||||
labels: { severity: critical }
|
labels: { severity: critical }
|
||||||
annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' }
|
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/*
|
@shell not path /assets/*
|
||||||
header @shell Cache-Control "no-cache"
|
header @shell Cache-Control "no-cache"
|
||||||
|
|
||||||
# The static public offer page, rendered from ui/legal/offer_ru.md at build
|
# The public offer page (/offer/) is no longer served here: the contour caddy routes it to the
|
||||||
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
|
# render sidecar, which splices the live catalog price list into ui/legal/offer_ru.md. This
|
||||||
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
|
# container never sees /offer/, so it carries no offer assets (the vite emit-offer plugin is gone).
|
||||||
# landing shell below (whose index is landing.html). A bare /offer redirects in.
|
|
||||||
handle /offer {
|
|
||||||
redir * /offer/ permanent
|
|
||||||
}
|
|
||||||
handle /offer/* {
|
|
||||||
file_server {
|
|
||||||
index index.html
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# An unknown path falls back to the landing shell (the gateway's old "/"
|
# An unknown path falls back to the landing shell (the gateway's old "/"
|
||||||
# behaviour); "/" itself resolves through the index below.
|
# 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 LOG_LEVEL='${LOG_LEVEL:-info}'
|
||||||
export DICT_VERSION='$DICT_VERSION'
|
export DICT_VERSION='$DICT_VERSION'
|
||||||
export APP_VERSION='$APP_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_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
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 PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
|
||||||
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
|
||||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
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
|
# 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
|
# 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
|
# operator sets the S3 repository values + secrets, creates the stanza and flips the switch
|
||||||
|
|||||||
@@ -100,15 +100,35 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
auth operations are unauthenticated and return the minted token. A unary
|
auth operations are unauthenticated and return the minted token. A unary
|
||||||
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP
|
||||||
200); only edge failures (rate limit, missing session, unknown type, internal)
|
200); only edge failures (rate limit, missing session, unknown type, internal)
|
||||||
surface as Connect error codes. The client treats a connectivity edge failure as
|
surface as Connect error codes. The client treats connectivity as **state, not a per-call toast**,
|
||||||
**state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global
|
via a single **net-state machine** — a pure reducer (`ui/src/lib/netstate.ts`) behind a reactive
|
||||||
`online` signal that drives a header **"Connecting…"** spinner and softly disables proactive
|
store (`netstate.svelte.ts`); `connection`/`offline` are thin derived shims over it. It has four
|
||||||
actions, and the transport **auto-retries with capped exponential backoff** — every op on a
|
states: `online`; `connecting` (a call or probe is failing but still inside the anti-flap window —
|
||||||
rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only**
|
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||||
ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose
|
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||||
response was lost — its button is disabled while offline and the player re-issues it on
|
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||||
reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no
|
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
|
||||||
other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal.
|
(a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
|
||||||
|
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) 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 from the offline-*play* model** —
|
||||||
|
`offlineMode.active` is 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. The machine still tracks reachability there, though: a
|
||||||
|
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
|
||||||
|
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
|
||||||
|
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
|
||||||
|
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
|
||||||
|
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
|
||||||
|
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
|
||||||
|
look-up hidden.
|
||||||
**Edge hardening:** every request body on the public listener is capped at
|
**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
|
`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
|
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||||
@@ -131,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||||
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
||||||
|
(carried on the request context), so the backend records the real caller — the
|
||||||
|
account's last-login IP, chat/feedback moderation, the access log — rather than
|
||||||
|
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||||
of 2 idle connections per host; otherwise the per-request connection churn
|
of 2 idle connections per host; otherwise the per-request connection churn
|
||||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||||
@@ -140,6 +163,40 @@ dropped). Horizontal scaling is explicit future work.
|
|||||||
(your-turn, opponent-moved, chat, nudge). The gateway bridges them to the
|
(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
|
client's in-app stream while the app is open. Out-of-app delivery uses
|
||||||
platform-native push via the platform side-service.
|
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
|
## 3. Authentication & sessions
|
||||||
|
|
||||||
@@ -224,6 +281,19 @@ arrive from a platform rather than completing a mandatory registration).
|
|||||||
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
`BACKEND_GUEST_REAP_INTERVAL` sweep, so transient guest rows do not accumulate.
|
||||||
Platform and email users are auto-provisioned **durable** accounts with an
|
Platform and email users are auto-provisioned **durable** accounts with an
|
||||||
identity.
|
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
|
> **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
|
> two-bot model (one bot per service language, with `accounts.service_language`, a
|
||||||
@@ -435,7 +505,11 @@ Key points:
|
|||||||
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
through a **session-gated `GET /dict/{variant}/{version}`** edge route
|
||||||
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
|
||||||
miss, storage eviction or a bad-connection breaker falls back to the network
|
miss, storage eviction or a bad-connection breaker falls back to the network
|
||||||
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
|
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
|
||||||
|
play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
|
||||||
|
separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
|
||||||
|
tiles, so it works regardless of which eval path produced the preview (the badge's number still
|
||||||
|
comes from the preview's score): legality and score stay with the eval, geometry with the board.
|
||||||
|
|
||||||
## 6. Game rules
|
## 6. Game rules
|
||||||
|
|
||||||
@@ -905,6 +979,26 @@ 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
|
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
|
||||||
image option is not offered.
|
image option is not offered.
|
||||||
|
|
||||||
|
The same sidecar also serves the **public legal pages** — the offer at `/offer/`, the privacy
|
||||||
|
policy at `/privacy/` and the EULA at `/eula/` — the edge-exposed routes on it (caddy routes them
|
||||||
|
here; its `/render` stays internal). All three reuse the shared `ui/src/lib/offer.ts`
|
||||||
|
`renderLegalHtml` (one renderer, no drift) over their owner-edited `ui/legal/*_{ru,en}.md` — each is
|
||||||
|
**bilingual (RU + EN)** with a client-side language + theme switcher (default Russian, persisted;
|
||||||
|
the offer's EN view transliterates the Russian product names) — baked into the image. `/privacy/`
|
||||||
|
and `/eula/` are static; the offer additionally splices in the **live price list** (§4.4) into both
|
||||||
|
languages: it
|
||||||
|
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
|
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 —
|
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
|
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
|
||||||
@@ -995,6 +1089,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.
|
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).
|
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),
|
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
|
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
|
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
|
||||||
@@ -1053,7 +1161,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
|
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
|
||||||
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
|
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
|
||||||
and the gateway↔validator and bot-link calls; the gateway also exports
|
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**
|
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
|
||||||
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
||||||
are stood up with the deploy (`deploy/`); the default exporter stays
|
are stood up with the deploy (`deploy/`); the default exporter stays
|
||||||
@@ -1170,6 +1280,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
|
(`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
|
the operator unbans the response returns, so a manual unban takes effect within the sync
|
||||||
interval.
|
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
|
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
|
||||||
database answers a bounded ping and the session cache is warmed).
|
database answers a bounded ping and the session cache is warmed).
|
||||||
- The backend serves a **second listener** — a gRPC server
|
- The backend serves a **second listener** — a gRPC server
|
||||||
@@ -1259,7 +1381,9 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
|
|||||||
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||||
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
||||||
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
||||||
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
|
parameters from the URL and the gateway verifies them in-process — §12; on that path without a
|
||||||
|
signed launch the client renders the same shareable launch-diagnostic screen rather than starting
|
||||||
|
a throwaway guest); a stray hit on the
|
||||||
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
@@ -1284,13 +1408,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
|
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
|
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
|
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
|
for install on Android) and powers **offline play**. Offline is **implicit** — the net-state machine
|
||||||
toggle — distinct from the transient gateway-reachability signal — that tints the header blue with
|
(§2) detects lost connectivity and tints the header blue with an *Offline* chip; there is **no toggle**
|
||||||
an *Offline* chip and confines play to on-device `vs_ai` games. The **offline lobby lists only those
|
(the old deliberate Settings switch and its cold-start dialog are gone). The **unified lobby** merges the
|
||||||
device-local games** (reconstructed by replaying the IndexedDB move journal) and its New-vs-AI entry
|
device-local games (active — reconstructed by replaying the IndexedDB move journal) with the last-cached
|
||||||
creates one through the in-browser engine — the same game screen then drives it, the robot replying
|
**server games shown greyed** (un-openable, a tap toasts *offline*); the Stats tab is disabled and
|
||||||
locally; online-only affordances (the Stats tab, the random-opponent option) are disabled
|
invitations are hidden while offline. On offline-capable channels (native / plain web) New Game's *with
|
||||||
or hidden, and New Game's *with friends* becomes the entry to **local pass-and-play (hotseat)**.
|
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` +
|
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
|
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
|
(referee) PIN** gates the roster at creation and, in-game, the referee overrides — **skip** the
|
||||||
@@ -1318,36 +1447,28 @@ 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
|
— 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
|
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
|
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
|
the ad-banner slot. A **cold launch with no network** boots offline-first from the persisted session +
|
||||||
bounded by a ~5 s UI wait (`raceOfflineReady` + the lazy `dict/offlineready`): it enters offline only
|
profile (saved on every online adopt/refresh) — `bootstrap` skips the session adoption and profile fetch
|
||||||
once every enabled variant is ready, otherwise it stays online with a *needs internet* note while the
|
that would otherwise hang, and lands straight in the offline lobby; a native launch with no server
|
||||||
fetch finishes in the background (the next flip is then instant). A **cold launch already in offline
|
session enters as a device-local guest (§3), and any stale pre-redesign offline-preference key is cleared
|
||||||
mode** boots from the persisted session and
|
on boot. Connectivity is **detected, not chosen** (the net-state machine, §2): a cold `navigator.onLine
|
||||||
profile (the profile is saved on every online adopt/refresh) — `bootstrap` skips the session
|
=== false` or a failed cold reachability probe (a bounded `profile.get`) enters offline, and mid-session
|
||||||
adoption and profile fetch that would otherwise hang with no network, and lands straight in the
|
the `navigator` / `@capacitor/network` `online`/`offline` events plus the probe watcher drive it — with
|
||||||
offline lobby; without a cached profile (an install that was never online) it drops the sticky flag
|
**hysteresis** so a brief blip lives in `connecting` and never flips the chrome, and **self-heal** (a
|
||||||
and boots online instead. Beyond the sticky toggle the app **auto-detects connectivity**: the
|
back-online toast) when the network returns. Offline is a real **transport kill switch** (every gateway
|
||||||
reactive flag carries an *auto* bit, so a self-entered offline (no network) is transient
|
call is refused, so no traffic leaks); the reachability probe is the one call exempt from it (it is the
|
||||||
(session-only, never persisted) and self-heals to online when the network returns, while a deliberate
|
return-to-online mechanism). The gateway registers the `.webmanifest` MIME type
|
||||||
one (the toggle, or the cold-start dialog's *Switch*) persists. At cold start `navigator.onLine ===
|
|
||||||
false` enters offline for the session; otherwise a single bounded reachability probe (a `profile.get`,
|
|
||||||
~3 s) decides — success boots online, a timeout on an eligible install raises a *No connection* dialog
|
|
||||||
(*Switch* = sticky offline, *Wait for network* = boot online with the reachability watcher retrying).
|
|
||||||
Mid-session the `window` `online`/`offline` events drive it — `offline` auto-enters, `online`
|
|
||||||
re-verifies reachability before returning — backed by a `navigator.onLine` poll because those events
|
|
||||||
are unreliable on some platforms (notably iOS PWAs). Offline is a real **transport kill switch**
|
|
||||||
(every gateway call is refused, so no traffic leaks); the reachability probe is the one call exempt
|
|
||||||
from it (it is the return-to-online mechanism), and the transient reachability watcher is suppressed
|
|
||||||
while offline. The gateway registers the `.webmanifest` MIME type
|
|
||||||
in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/*` are served
|
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
|
`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
|
`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
|
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
|
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
|
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
|
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the public
|
||||||
catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
|
legal pages `/offer/`, `/privacy/` and `/eula/` (rendered by the `renderer` sidecar — the offer with
|
||||||
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
|
the live catalog price list spliced in) go 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**,
|
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
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
|
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
|
||||||
@@ -1378,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
|||||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
||||||
|
last-login IP, chat/feedback moderation and the access log — survives the
|
||||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||||
@@ -1419,6 +1541,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
|
client IPs). The `vpn`+`bot` pair is gated to a `telegram-local` compose profile the test
|
||||||
contour activates; the prod main host omits it.
|
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. Runbook:
|
||||||
|
[`../deploy/README.md`](../deploy/README.md).
|
||||||
|
|
||||||
## 14. CI & branches
|
## 14. CI & branches
|
||||||
|
|
||||||
- **Two long-lived branches**: **`development`** is the integration
|
- **Two long-lived branches**: **`development`** is the integration
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ 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
|
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
|
(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
|
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 three **legal
|
||||||
|
documents** — the **user agreement** (`/eula/`), the **privacy policy** (`/privacy/`) and the
|
||||||
|
**public offer** (`/offer/`) — and, beside them, **feedback** (the Telegram bot the documents name as
|
||||||
|
the seller's contact). Each is **bilingual (RU/EN)** with a language + theme switcher and is rendered
|
||||||
|
from its `ui/legal/*_{ru,en}.md` sources by the render sidecar; the offer additionally splices in its
|
||||||
|
**price list** (§4.4) generated from the live product catalog, so the published prices always match
|
||||||
|
what is currently on sale — no redeploy to update them.
|
||||||
|
|
||||||
On the plain web the client is an **installable PWA**: a logged-out player sees an install
|
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
|
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
|
||||||
@@ -66,7 +72,10 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
|
|||||||
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
||||||
the name in the signed launch). While the theme preference is "auto" the app follows the VK
|
the name in the signed launch). While the theme preference is "auto" the app follows the VK
|
||||||
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
|
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
|
||||||
"couldn't load" screen applies inside VK.
|
"couldn't load" screen applies inside VK. Opening a Mini App link directly in an ordinary browser —
|
||||||
|
the `/vk/` entry with no signed VK launch, or `/telegram/` with no Telegram sign-in data — shows a
|
||||||
|
compact, shareable diagnostic screen instead of proceeding (as a throwaway guest on VK, or by
|
||||||
|
redirecting away on Telegram), so a player who ends up there wrongly can screenshot it for us.
|
||||||
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
|
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
|
||||||
(en/ru), no images — to the address; entering it signs the player in. A new address
|
(en/ru), no images — to the address; entering it signs the player in. A new address
|
||||||
is provisioned on the first request but only becomes a durable account once the code
|
is provisioned on the first request but only becomes a durable account once the code
|
||||||
@@ -204,19 +213,26 @@ client, and refuses a guest's friend request, friend code or invitation outright
|
|||||||
|
|
||||||
### Playing a game
|
### Playing a game
|
||||||
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
|
||||||
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
|
choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
|
||||||
|
bag are shown as a count on that control and at the foot of the move table. Tiles are laid
|
||||||
without choosing a direction — the game infers the play's orientation, so a single
|
without choosing a direction — the game infers the play's orientation, so a single
|
||||||
tile that extends
|
tile that extends
|
||||||
an existing word (down a column or across a row) is accepted. A play is validated
|
an existing word (down a column or across a row) is accepted. A play is validated
|
||||||
against the game's dictionary at submit time and scored; an unlimited preview
|
against the game's dictionary at submit time and scored; an unlimited preview shows
|
||||||
reports the word(s) a tentative move would form and its score, or that it is not
|
**on the board itself** whether the tentative move is legal — the staged tiles turn a light
|
||||||
legal, and the move is offered for submission only once it is confirmed legal. The preview is
|
green once they form a word, a shade darker on the board tiles the word runs through (in a
|
||||||
|
one-word-per-turn game only the main word), or a calm pink when they form none — and an
|
||||||
|
**orange badge** on the main word carries the move's
|
||||||
|
score. The confirm control is offered only once the move is legal. The preview is
|
||||||
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
computed **on-device** for an instant response once the game's dictionary has loaded — a
|
||||||
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
|
||||||
local dictionary is unavailable. The dictionary check tool is
|
local dictionary is unavailable. The dictionary check tool is
|
||||||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||||
English) to look it up. Hints are governed per game —
|
English) to look it up. While offline in an online game the check runs against the game's own
|
||||||
|
dictionary on the device — the same verdict as the server when that dictionary is available,
|
||||||
|
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
|
||||||
|
need the network, are hidden. Hints are governed per game —
|
||||||
whether they are allowed and how many each player starts with — and draw on a
|
whether they are allowed and how many each player starts with — and draw on a
|
||||||
personal hint wallet once the per-game allowance is spent. Against the robot
|
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||||
@@ -269,22 +285,28 @@ add-friend are off. AI games are **practice** — they never count toward a play
|
|||||||
statistics.
|
statistics.
|
||||||
|
|
||||||
### Offline mode
|
### Offline mode
|
||||||
An installed web PWA signed in with a confirmed email can switch to a deliberate **offline mode**
|
The **web and native apps** play **offline automatically** — there is **no on/off switch**; the
|
||||||
(Settings → Play mode). Offline, the app never touches the network: the header turns blue with an
|
online-only **Telegram and VK mini-apps** never go offline (everything below applies to the web and
|
||||||
*Offline* chip, the lobby lists only the games stored on the device, and online-only surfaces are
|
native apps only). When it cannot reach the
|
||||||
disabled or hidden (the Stats tab; the *random opponent* option in New Game).
|
server the header turns blue with an *Offline* chip and play is confined to games on the device; a
|
||||||
A New Game against the robot creates a device-local **vs_ai** game — it
|
momentary blip is ridden out as a quiet *"Connecting…"* (it never drops you offline), and when the
|
||||||
plays entirely in the browser with no backend. Local games are kept on the device, visible only in
|
connection returns the app goes back online on its own with a brief *back online* toast. Offline, the
|
||||||
offline mode, and never sync to the account. So a game can be created and played with no connection,
|
app never touches the network.
|
||||||
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.
|
|
||||||
|
|
||||||
Offline, New Game's **with friends** starts a **local pass-and-play** game for 2-4 people sharing one
|
The **lobby stays unified**: your device-local games are active, while your server games are still
|
||||||
device. You first set a **host (leader) password** — a 4-digit PIN entered on a lock-screen-style
|
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 —
|
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
|
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**.
|
rows, where a removal asks for the leader password) and may give any seat its **own optional PIN**.
|
||||||
@@ -297,14 +319,42 @@ 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,
|
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.
|
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
|
Going offline and coming back are both **automatic**. On a cold launch with no connection the app
|
||||||
with no connection it switches to offline for that session; when the device is online but the gateway
|
starts straight in the offline lobby; while it is open, losing the network — airplane mode — turns it
|
||||||
stays silent for a few seconds it asks first — *No connection. Switch to offline mode?*, with
|
offline on its own, and restoring the network returns it online by itself. There is no dialog and no
|
||||||
**Switch** (go offline) or **Wait for network** (keep retrying online). While the app is open, losing
|
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||||
the network — airplane mode — switches to offline automatically, and restoring it returns online by
|
offline, so you are never interrupted for a hiccup.
|
||||||
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
|
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||||||
dialog — is the player's choice: it is kept, and never undone automatically.
|
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
||||||
|
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
||||||
|
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||||||
|
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
|
||||||
|
board as a draft. Nothing
|
||||||
|
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
||||||
|
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
||||||
|
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
||||||
|
on-device dictionary.
|
||||||
|
|
||||||
|
### 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
|
### Social: friends, block, chat, nudge
|
||||||
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
Become friends in two ways: redeem a **one-time code** the other player issues (six
|
||||||
@@ -372,7 +422,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
|
|||||||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||||
entry that opens this screen, and your display preferences (theme, board-label style and
|
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||||
reduce-motion — not the interface language, which follows your account) sync across your
|
reduce-motion — not the interface language, which follows your account) sync across your
|
||||||
Telegram devices.
|
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
|
||||||
|
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
|
||||||
|
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
|
||||||
|
|
||||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||||
@@ -397,9 +449,11 @@ unanswered reply. Guests cannot send feedback (the entry is hidden). A player th
|
|||||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||||
|
|
||||||
### Telegram support chat
|
### Telegram support chat
|
||||||
A user can also reach the operators straight from the Telegram bot: anything they send the bot
|
A user can also reach the operators straight from the Telegram bot. The `/support` command replies
|
||||||
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
|
with the support desk's working hours and what to include (a description and, when possible,
|
||||||
private support group, grouped into a per-user thread. The user sees no automatic reply; an
|
screenshots). Anything else they send the bot other than `/start` — text, a photo, a voice message,
|
||||||
|
a file — is forwarded into the operators' private support group, grouped into a per-user thread. The
|
||||||
|
user sees no automatic reply to a forwarded message; an
|
||||||
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
||||||
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
||||||
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
||||||
@@ -502,7 +556,8 @@ at any time (games already lost stay lost).
|
|||||||
|
|
||||||
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
|
||||||
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
|
||||||
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
or are unblocked. It also clears the automatic pin Telegram puts on each channel post that is
|
||||||
|
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. So an unregistered newcomer who comments is muted (the promo bot points them at the
|
||||||
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
game to register, after which the bot restores their voice), and a registered, unblocked player simply
|
||||||
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
|
||||||
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
||||||
@@ -528,21 +583,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
|
|||||||
### Wallet
|
### Wallet
|
||||||
|
|
||||||
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
|
||||||
none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
|
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
|
||||||
or the web), their **active benefits** (ads off until a date or forever, and the available hint
|
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
|
||||||
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
|
the web, split by where they were funded. What is visible and spendable depends on **where the app
|
||||||
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
|
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
|
||||||
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
|
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
|
||||||
shown but frozen for spending.
|
VK-iOS the balance is shown but frozen for spending.
|
||||||
|
|
||||||
|
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
|
||||||
|
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
|
||||||
|
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
|
||||||
|
|
||||||
|
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
|
||||||
|
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
|
||||||
|
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
|
||||||
|
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
|
||||||
|
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
|
||||||
|
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
|
||||||
|
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
|
||||||
|
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
|
||||||
|
|
||||||
The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
|
|
||||||
price shown in every context, and **chip packs** bought with real money, priced in the running
|
|
||||||
context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
|
|
||||||
benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
|
|
||||||
benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
|
|
||||||
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
|
||||||
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
|
||||||
**no purchase history** — only the current balances, benefits and store.
|
**no purchase history** — only the current balances, active benefits and store.
|
||||||
|
|
||||||
### Advertising banner
|
### Advertising banner
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,13 @@ top-1 подсказку, безлимитную проверку слова с
|
|||||||
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
|
||||||
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
|
||||||
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
|
||||||
`noindex` — индексируется только посадочная страница.
|
`noindex` — индексируется только посадочная страница. В подвале — ссылки на три **юридических
|
||||||
|
документа** — **пользовательское соглашение** (`/eula/`), **политику конфиденциальности**
|
||||||
|
(`/privacy/`) и **публичную оферту** (`/offer/`) — и рядом на **обратную связь** (тот самый
|
||||||
|
Telegram-бот, который документы указывают как контакт Продавца). Каждый — **двуязычный (RU/EN)** с
|
||||||
|
переключателем языка и темы, рендерится из исходников `ui/legal/*_{ru,en}.md` сайдкаром-рендерером;
|
||||||
|
оферта дополнительно подставляет **перечень стоимости** (§4.4), формируемый из живого каталога
|
||||||
|
товаров, поэтому опубликованные цены всегда совпадают с тем, что сейчас в продаже — без передеплоя.
|
||||||
|
|
||||||
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
|
||||||
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
|
||||||
@@ -71,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||||
тихого повтора «не удалось
|
тихого повтора «не удалось
|
||||||
загрузить» действует и внутри VK.
|
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
|
||||||
|
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
|
||||||
|
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
|
||||||
|
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
|
||||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||||
@@ -212,18 +221,24 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
|
|
||||||
### Игровой процесс
|
### Игровой процесс
|
||||||
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
|
||||||
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
|
без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
|
||||||
|
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
|
||||||
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
|
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
|
||||||
уже лежащее
|
уже лежащее
|
||||||
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
|
||||||
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
|
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
|
||||||
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
|
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
|
||||||
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
|
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
|
||||||
|
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
|
||||||
|
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
|
||||||
|
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
|
||||||
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
|
||||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||||
чтобы его посмотреть. Подсказки управляются настройками
|
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
|
||||||
|
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
|
||||||
|
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
|
||||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||||
@@ -274,22 +289,27 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
редкими ходами вопреки плану, затухающими к эндшпилю), а чат, nudge и «добавить в друзья» выключены. Партии с ИИ — это **тренировка**: они не идут в статистику игрока.
|
||||||
|
|
||||||
### Офлайн-режим
|
### Офлайн-режим
|
||||||
Установленный веб-PWA со входом по подтверждённой почте может переключиться в осознанный
|
**Веб- и нативное приложения** играют **офлайн автоматически** — никакого переключателя нет; онлайн-только
|
||||||
**офлайн-режим** (Настройки → Режим игры). В офлайне приложение не обращается к сети: шапка синеет с
|
**мини-приложения Telegram и VK** никогда не уходят в офлайн (всё ниже относится только к веб- и нативному
|
||||||
меткой *Офлайн*, лобби показывает только сохранённые на устройстве игры, а сетевые поверхности
|
приложениям). Когда оно не может достучаться
|
||||||
отключены или скрыты (вкладка Статистика; вариант «случайный соперник» в Новой игре).
|
до сервера, шапка синеет с меткой *Офлайн*, а игра ограничивается партиями на устройстве; кратковременный
|
||||||
Новая игра против робота создаёт локальную **vs_ai**-игру — он ходит
|
сбой пережидается как тихое *«Подключение…»* (в офлайн не роняет), а при возврате связи приложение само
|
||||||
целиком в браузере без бэкенда. Локальные игры хранятся на устройстве, видны только в офлайн-режиме и
|
возвращается онлайн с коротким тостом *соединение восстановлено*. В офлайне приложение не обращается к
|
||||||
никогда не синхронизируются с аккаунтом. Чтобы игру можно было создать и сыграть без связи, приложение
|
сети.
|
||||||
заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки)
|
|
||||||
запускается из прекешированного шелла даже без сети. Переключение **в** офлайн сначала проверяет, что
|
|
||||||
словари включённых вариантов есть на устройстве — если каких-то не хватает, оно их подгружает и недолго
|
|
||||||
ждёт, а если подготовить их не удаётся, остаётся онлайн с короткой заметкой *нужен интернет* (загрузка
|
|
||||||
продолжается в фоне, так что следующее переключение мгновенно). Режим привязан к устройству и
|
|
||||||
сохраняется между запусками.
|
|
||||||
|
|
||||||
В офлайне «с друзьями» в Новой игре запускает **локальную игру по очереди (hotseat)** на 2–4 человек
|
**Лобби остаётся единым**: локальные игры на устройстве активны, а серверные игры по-прежнему видны, но
|
||||||
за одним устройством. Сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
**затемнены** — их видно, но открыть нельзя, пока не вернётесь онлайн (тап поясняет почему). Сетевые
|
||||||
|
поверхности (вкладка Статистика) отключены, а приглашения в офлайне скрыты. Новая игра против робота
|
||||||
|
создаёт локальную **vs_ai**-игру, которая идёт целиком на устройстве без бэкенда; локальные игры хранятся
|
||||||
|
на устройстве и никогда не синхронизируются с аккаунтом, так что игру можно создать и сыграть без связи.
|
||||||
|
Приложение заранее, пока ещё онлайн, подгружает словари включённых игроком вариантов и (после установки
|
||||||
|
или в нативном приложении) запускается из вшитых/прекешированных данных даже без сети; если словарь
|
||||||
|
варианта недоступен офлайн, создание такой игры отключается с короткой заметкой.
|
||||||
|
|
||||||
|
«С друзьями» в Новой игре предлагает выбор — **пригласить друга** для игры онлайн или **локальную игру
|
||||||
|
по очереди (hotseat)** на 2–4 человек за одним устройством; без сети доступна только игра по очереди.
|
||||||
|
(В онлайн-только мини-приложениях Telegram/VK «с друзьями» — это только приглашение друга, без игры по очереди.)
|
||||||
|
В игре по очереди сначала задаётся **пароль ведущего** — 4-значный PIN на клавиатуре в стиле
|
||||||
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
экрана блокировки; пока он не задан, строки игроков заблокированы. Затем приложение спрашивает,
|
||||||
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
играете ли вы сами — если да, вы занимаете первое место под именем из профиля. Вы называете каждого
|
||||||
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
игрока (от 2 до 4; строки можно добавлять и удалять, удаление спрашивает пароль ведущего) и можете
|
||||||
@@ -303,14 +323,42 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
|||||||
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
идущей или завершённой — из лобби спрашивает пароль ведущего, чтобы никто не стёр партию сразу после
|
||||||
своего хода.
|
своего хода.
|
||||||
|
|
||||||
Приложение также **само включает офлайн-режим**, когда не может достучаться до сети. При холодном
|
Уход в офлайн и возврат — оба **автоматические**. При холодном запуске без связи приложение сразу
|
||||||
запуске без связи оно переходит в офлайн на текущую сессию; когда устройство онлайн, но шлюз молчит
|
стартует в офлайн-лобби; пока оно открыто, потеря сети — режим полёта — сама уводит в офлайн, а
|
||||||
несколько секунд, оно сперва спрашивает — *Нет связи. Включить офлайн-режим?*, с выбором **Включить**
|
восстановление сети само возвращает онлайн. Ни диалога, ни переключателя, который надо помнить:
|
||||||
(уйти в офлайн) или **Ждать сеть** (продолжить попытки онлайн). Пока приложение открыто, потеря сети —
|
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||||
режим полёта — переключает в офлайн автоматически, а её восстановление само возвращает онлайн.
|
вас не прерывают из-за короткой икоты.
|
||||||
Самостоятельно включённый офлайн временный: он возвращается в онлайн, как только сеть появилась, и
|
|
||||||
следующий запуск проверяет заново. **Осознанный** офлайн — тумблер в Настройках или **Включить** в том
|
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||||
диалоге — это выбор игрока: он сохраняется и никогда не отменяется автоматически.
|
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||||
|
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||||
|
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||||
|
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||||
|
Ничего не отправляется;
|
||||||
|
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||||
|
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||||
|
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||||
|
|
||||||
|
### Актуальная версия
|
||||||
|
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||||
|
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||||
|
(закрыть и продолжить играть на устройстве); в вебе вместо этого предлагается **Обновить** (перезагрузка).
|
||||||
|
Более мягкий, ненавязчивый баннер **«Доступно обновление»** появляется в лобби, когда есть новее — но пока
|
||||||
|
не обязательная — версия; его можно обновить или закрыть, игра продолжается в любом случае.
|
||||||
|
|
||||||
|
### Нативное приложение (Android)
|
||||||
|
Игра также выходит как отдельное **приложение для Android** (сначала в **RuStore**) — упакованная сборка
|
||||||
|
того же клиента. Всё необходимое встроено, поэтому оно открывается **без сети**: первый запуск офлайн сразу
|
||||||
|
открывает лобби как **гость** (без экрана входа), и можно играть немедленно — против робота или в игру **по
|
||||||
|
очереди на одном устройстве** (pass-and-play) с друзьями — используя словари, вшитые в приложение. Когда сеть
|
||||||
|
доступна, приложение тихо заводит гостя в фоне, и онлайн-возможности (случайные соперники, друзья,
|
||||||
|
статистика) включаются, не прерывая игру; локальные игры, начатые офлайн, остаются на устройстве. Чтобы
|
||||||
|
сохранить постоянный аккаунт, вход выполняется по **email** из Профиля (гость становится вашим аккаунтом);
|
||||||
|
вход через Telegram и VK в нативном приложении не предлагается. Встроенные покупки в этом первом релизе
|
||||||
|
скрыты. Поскольку установленное приложение может быть намного старше сервера, сборка, которая **слишком
|
||||||
|
стара** для общения с текущим сервером, показывает единственный несбрасываемый экран *обновления*, кнопка
|
||||||
|
которого открывает страницу в магазине — он появляется только при онлайн-действии, никогда во время
|
||||||
|
офлайн-игры.
|
||||||
|
|
||||||
### Социальное: друзья, блок, чат, nudge
|
### Социальное: друзья, блок, чат, nudge
|
||||||
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
Подружиться можно двумя способами: погасить **одноразовый код**, который выпускает
|
||||||
@@ -381,7 +429,10 @@ UTC; при создании аккаунта она подставляется
|
|||||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||||
Telegram.
|
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
|
||||||
|
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
|
||||||
|
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
|
||||||
|
где доска и так помещается целиком и авто-зума нет.
|
||||||
|
|
||||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||||
@@ -406,9 +457,11 @@ Telegram.
|
|||||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||||
|
|
||||||
### Чат поддержки в Telegram
|
### Чат поддержки в Telegram
|
||||||
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
|
С операторами можно поговорить и прямо из Telegram-бота. Команда `/support` отвечает графиком работы
|
||||||
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
|
поддержки и напоминанием, что приложить (подробное описание и, по возможности, скриншоты). Всё
|
||||||
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
|
остальное, что пользователь присылает боту, кроме `/start` — текст, фото, голосовое, файл, —
|
||||||
|
пересылается в закрытую группу поддержки операторов и собирается в отдельную ветку на пользователя.
|
||||||
|
Пользователь не получает автоответа на пересланное сообщение; оператор
|
||||||
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
||||||
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
||||||
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
||||||
@@ -514,7 +567,9 @@ high-rate флага. С карточки пользователя операт
|
|||||||
|
|
||||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
|
||||||
|
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
|
||||||
|
сообщения. То есть незарегистрированного новичка, написавшего в чат,
|
||||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||||
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||||
@@ -541,21 +596,30 @@ high-rate флага. С карточки пользователя операт
|
|||||||
### Кошелёк
|
### Кошелёк
|
||||||
|
|
||||||
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
|
||||||
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
|
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
|
||||||
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
|
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
|
||||||
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
|
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
|
||||||
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
|
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
|
||||||
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
|
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
|
||||||
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
|
||||||
|
|
||||||
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
|
Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
|
||||||
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
|
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
|
||||||
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
|
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
|
||||||
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
|
|
||||||
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
|
- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
|
||||||
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
|
звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
|
||||||
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
|
Оплата открывает страницу провайдера (с принятием публичной оферты).
|
||||||
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
|
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
|
||||||
|
фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
|
||||||
|
баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
|
||||||
|
которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
|
||||||
|
магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
|
||||||
|
приложении.
|
||||||
|
|
||||||
|
В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
|
||||||
|
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
|
||||||
|
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
|
||||||
|
|
||||||
### Рекламный баннер
|
### Рекламный баннер
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Icon & logo asset map
|
||||||
|
|
||||||
|
> The whole shipped icon set is sliced from **one master**. This file is the per-platform
|
||||||
|
> **target map** (which file goes where, at what size). How the mark itself is constructed
|
||||||
|
> — proportions, palette, wood grain, the light/shadow gradients, the «Э» + ✻ placement,
|
||||||
|
> all in fractions of the side — is specified in [`ICON_BRANDBOOK.md`](ICON_BRANDBOOK.md).
|
||||||
|
> The reference generator, the pinned Spectral font and the committed masters live in
|
||||||
|
> [`../assets/icons/brand/`](../assets/icons/brand/).
|
||||||
|
|
||||||
|
## The master (one source, six layers)
|
||||||
|
|
||||||
|
`brand/build-icon.mjs` emits any `--variant light|dark` in any `--layer`:
|
||||||
|
|
||||||
|
| Layer | What | Feeds |
|
||||||
|
|---|---|---|
|
||||||
|
| `full` | rounded tile + master mark | `favicon.svg` |
|
||||||
|
| `flat` | square tile + master mark (OS rounds) | apple-touch, PWA "any", VK, TG, store |
|
||||||
|
| `background` | square tile only, full-bleed | Android adaptive background |
|
||||||
|
| `foreground` | mark only, transparent, re-placed for the round mask | Android adaptive foreground |
|
||||||
|
| `maskable` | square tile + foreground-placed mark | PWA `maskable` |
|
||||||
|
| `monochrome` | foreground-placed silhouette, flat colour | Android 13+ themed layer |
|
||||||
|
|
||||||
|
`brand/build-set.mjs` renders every raster below; `brand/build-android-res.mjs` renders
|
||||||
|
the Android launcher resources.
|
||||||
|
|
||||||
|
## Generated targets
|
||||||
|
|
||||||
|
### Web — `ui/public/` (via `brand/build-set.mjs`)
|
||||||
|
|
||||||
|
| File | Size | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| `favicon.svg` | vector | **light + dark in one file** via `prefers-color-scheme` |
|
||||||
|
| `favicon.ico` | 32×32 | PNG-in-ICO; answers the blind `/favicon.ico` probe |
|
||||||
|
| `apple-touch-icon.png` | 180×180 | opaque full-bleed (iOS masks its own corners) |
|
||||||
|
| `icon-192.png` / `icon-512.png` | 192 / 512 | PWA `any`, light |
|
||||||
|
| `icon-192-dark.png` / `icon-512-dark.png` | 192 / 512 | dark variants (generated; see PWA note) |
|
||||||
|
| `icon-maskable-512.png` | 512×512 | PWA `maskable`, light |
|
||||||
|
| `icon-maskable-512-dark.png` | 512×512 | dark variant (generated; see PWA note) |
|
||||||
|
| `og-image.png` | 1200×630 | board-green card: master tile + «Эрудит» / Игра в слова |
|
||||||
|
|
||||||
|
### PWA — `ui/public/manifest.webmanifest`
|
||||||
|
|
||||||
|
Referenced icons are the **light** set (`icon-192`, `icon-512` = `any`;
|
||||||
|
`icon-maskable-512` = `maskable`). The manifest has **no reliable way to pick an icon by
|
||||||
|
colour scheme**, so the dark PNGs are **generated but not referenced** — theming the
|
||||||
|
in-browser tab is handled by `favicon.svg` instead. Wire the dark files in only if a
|
||||||
|
concrete installer target is known to honour them.
|
||||||
|
|
||||||
|
### Android — `ui/android/app/src/main/res/` (via `brand/build-android-res.mjs`)
|
||||||
|
|
||||||
|
Capacitor layer inputs are `ui/assets/{icon,icon-foreground,icon-background}.png`. The
|
||||||
|
resources are **hand-generated from the layers**, not `capacitor-assets` — our layers
|
||||||
|
already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
|
||||||
|
full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
|
||||||
|
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
|
||||||
|
|
||||||
|
- `mipmap-*/ic_launcher.png` — legacy square (< API 26): the **full-bleed master** (large
|
||||||
|
mark), so old square launchers do not shrink it into the safe zone
|
||||||
|
- `mipmap-*/ic_launcher_round.png` — legacy round (< API 26): the safe-zone **composite**,
|
||||||
|
circle-clipped (a round mask would clip the master's corner ✻)
|
||||||
|
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
|
||||||
|
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
|
||||||
|
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml` — **no `<inset>`**, with
|
||||||
|
`<background>` + `<foreground>` + `<monochrome>` (committed by hand)
|
||||||
|
|
||||||
|
### iOS / iPhone / iPad — FUTURE
|
||||||
|
|
||||||
|
No iOS shell today. When one exists, `flat` (opaque, no alpha, no rounded corners; the OS
|
||||||
|
masks) drives the asset catalog — a single 1024×1024 marketing icon (Xcode derives the
|
||||||
|
rest), plus light/dark/tinted where the catalog supports it.
|
||||||
|
|
||||||
|
### VK / Telegram / store — manual upload (`brand/{vk,tg,store}/`)
|
||||||
|
|
||||||
|
Square, **no rounding** (each platform crops/rounds itself), light `flat`:
|
||||||
|
|
||||||
|
| Folder | Files | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `vk/` | `vk-576/278/150/32.png` | VK community / app icons |
|
||||||
|
| `tg/` | `tg-512.png` | Telegram bot avatar + Mini App icon (512, square; TG crops circular) |
|
||||||
|
| `tg/` | `tg-demo-640x360.png` | BotFather `/newapp` demo banner (tile + wordmark) |
|
||||||
|
| `store/` | `rustore-512.png` | RuStore app icon |
|
||||||
|
| RuStore | screenshots / feature graphic | per the RuStore listing spec (uploaded by hand) |
|
||||||
|
| Google Play (future) | icon / feature graphic | 512×512 / 1024×500 |
|
||||||
|
| App Store (future) | marketing icon / screenshots | 1024×1024 / per device class |
|
||||||
|
|
||||||
|
## Regenerate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons/brand
|
||||||
|
npm i # opentype.js (rasterisation reuses the ui package's chromium)
|
||||||
|
node build-set.mjs # web (ui/public) + Capacitor layers (ui/assets) + vk/ tg/ store/ + og
|
||||||
|
node build-android-res.mjs # Android launcher resources (all densities + monochrome)
|
||||||
|
```
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Erudit app-icon — brand book
|
||||||
|
|
||||||
|
The single source of truth for **how the Erudit icon is constructed**. It is written to
|
||||||
|
be reproducible *from words alone*: a designer with this document and the Spectral font
|
||||||
|
can rebuild the icon exactly, at any size, without pixel-matching anything.
|
||||||
|
|
||||||
|
Two rules make that possible:
|
||||||
|
|
||||||
|
1. **The icon is a square.** Every size and position below is a **fraction of the
|
||||||
|
square's side** (equivalently, a percentage). Nothing is in pixels. Draw the square
|
||||||
|
at whatever resolution you need and scale each value to it.
|
||||||
|
2. **The origin is the top-left corner.** `x` grows right, `y` grows down. So "center
|
||||||
|
`48.1% / 49.2%`" means a point `0.481·side` from the left and `0.492·side` from the top.
|
||||||
|
|
||||||
|
The companion generator in [`../assets/icons/brand/`](../assets/icons/brand/) is the
|
||||||
|
machine version of this exact spec (`build-icon.mjs`, where each constant is the same
|
||||||
|
fraction printed here). The `docs/ICONS.md` map covers the *opposite* concern — which
|
||||||
|
files/sizes we slice this master into for each platform.
|
||||||
|
|
||||||
|
## Anatomy
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Bottom-to-top the icon is six layers: **tile → wood grain → light sheen → shadow →
|
||||||
|
letter «Э» → star ✻**. The letter and star are always drawn last, at full ink colour;
|
||||||
|
the grain and the two gradients only ever touch the background.
|
||||||
|
|
||||||
|
## Palette
|
||||||
|
|
||||||
|
The icon ships as a **light** and a **dark** variant (a matched pair — the light tile
|
||||||
|
carries dark ink, the dark tile carries light ink). Each variant uses four colours.
|
||||||
|
|
||||||
|
| Role | Light | Dark |
|
||||||
|
|------|-------|------|
|
||||||
|
| **Tile** — the wooden body | `#e6b053` | `#4a3316` |
|
||||||
|
| **Ink** — the «Э» and the ✻ | `#5a3a12` | `#f2d9a0` |
|
||||||
|
| **Grain** — the vertical stripes | `#d8a54e` | `#432e14` |
|
||||||
|
| **Sheen** — diagonal light | white, α 0 → 15 % | white, α 0 → 15 % |
|
||||||
|
| **Shadow** — diagonal dark | black, α 15 → 0 % | black, α 15 → 0 % |
|
||||||
|
|
||||||
|
The grain colour is intentionally close to the tile — a hair darker — so the stripes
|
||||||
|
read as wood texture, not as stripes.
|
||||||
|
|
||||||
|
## 1 — Tile
|
||||||
|
|
||||||
|
A **square with rounded corners**, corner radius **17 %** of the side, filled with the
|
||||||
|
**Tile** colour. This is the whole background; there is no separate outer frame or
|
||||||
|
border. Corners are baked at 17 %; platform masks (iOS/Android) may round further on top.
|
||||||
|
|
||||||
|
## 2 — Wood grain
|
||||||
|
|
||||||
|
Subtle vertical planks over the tile:
|
||||||
|
|
||||||
|
- Divide the width into **6 equal columns** (each `1/6` of the side wide).
|
||||||
|
- On the **right edge of each column**, draw one **vertical stripe**, width **`1/32` of
|
||||||
|
the side** (≈ 3.1 %), running the full height.
|
||||||
|
- Move the **whole set of six stripes left** by **`1/16` of the side** (= two stripe
|
||||||
|
widths).
|
||||||
|
- **Tilt every stripe 4°** (clockwise, about its own centre).
|
||||||
|
- Draw the stripes **past the top and bottom edges** so the tilt leaves no empty corners,
|
||||||
|
then **clip the whole set to the tile** (so the rounded corners stay clean).
|
||||||
|
- Colour: **Grain**.
|
||||||
|
|
||||||
|
## 3 — Light sheen · 4 — Shadow
|
||||||
|
|
||||||
|
Two linear gradients along the **same diagonal**, from the **bottom-left corner to the
|
||||||
|
top-right corner**, each clipped to the tile and sitting **above the grain, below the
|
||||||
|
letter**:
|
||||||
|
|
||||||
|
- **Sheen** — white. Fully transparent at the bottom-left, rising to **15 % opacity** at
|
||||||
|
the top-right. Brightens the top-right.
|
||||||
|
- **Shadow** — black. **15 % opacity** at the bottom-left, fading to transparent at the
|
||||||
|
top-right. Deepens the bottom-left.
|
||||||
|
|
||||||
|
Together they give the tile a lit-from-the-top-right, resting-in-shadow-at-the-bottom-left
|
||||||
|
sense of depth. Both percentages are a tuning knob (`--sheen`, `--shade`).
|
||||||
|
|
||||||
|
## 5 — The letter «Э»
|
||||||
|
|
||||||
|
- Typeface: **Spectral, Bold** (SIL OFL; pinned in the brand folder).
|
||||||
|
- Glyph: the Cyrillic capital **«Э»** (U+042D).
|
||||||
|
- Size: scale the glyph so its **ink bounding box is 60.5 % of the side tall** (its width
|
||||||
|
then follows the font — about 53.2 %).
|
||||||
|
- Position: place the **center of that bounding box** at **48.1 % / 49.2 %** (just up and
|
||||||
|
left of the icon center).
|
||||||
|
- Colour: **Ink**.
|
||||||
|
|
||||||
|
## 6 — The star ✻
|
||||||
|
|
||||||
|
A **six-spoke teardrop asterisk** (the "score" mark that replaces a tile's point number):
|
||||||
|
|
||||||
|
- Each spoke tapers to a **point at the center** and ends in a **round bulb**; a central
|
||||||
|
disc equal to a bulb fuses the six spokes.
|
||||||
|
- Outer diameter: **17.3 % of the side**.
|
||||||
|
- Bulb radius (and the central disc): **22 % of the star's radius**.
|
||||||
|
- Position: center at **78.8 % / 78.8 %** — tucked to the lower-right of the «Э», like a
|
||||||
|
subscript, with a clear gap from the letter.
|
||||||
|
- Colour: **Ink**.
|
||||||
|
|
||||||
|
Spectral has no ✻ glyph, so the star is **drawn geometrically**, not set as type. The exact
|
||||||
|
construction is in `build-icon.mjs` (`starPath`).
|
||||||
|
|
||||||
|
## Composition intent
|
||||||
|
|
||||||
|
The «Э» and the ✻ are treated as **one group**. Its combined bounding box measures
|
||||||
|
about **65.9 % × 68.6 %** of the side, and it is placed so its **bottom-right corner sits
|
||||||
|
at 87.5 % / 87.5 %** of the icon (the lower-right corner of an inner `12.5 % … 87.5 %`
|
||||||
|
safe square). That is what pushes the composition down-and-right and leaves the star room
|
||||||
|
in the corner. When re-deriving positions, this is the anchor to preserve.
|
||||||
|
|
||||||
|
> This bottom-right bias suits a **full-bleed** icon (iOS / PWA / favicon, which show
|
||||||
|
> almost the whole square). Android crops more, so it uses a **separate two-layer build**
|
||||||
|
> — see the next section. The master itself is never moved for Android.
|
||||||
|
|
||||||
|
## Android adaptive icon (two layers)
|
||||||
|
|
||||||
|
Android icons are **108×108 dp** and the OS applies its own mask (circle, squircle,
|
||||||
|
rounded square…). Only the **inner 66 dp = 61 % of the side, centred** is guaranteed
|
||||||
|
visible under every mask; the outer ~1/6 ring is used for the mask and motion effects.
|
||||||
|
So Android is **not** the full-bleed master — it is built as two layers:
|
||||||
|
|
||||||
|
- **Background** — the tile with wood grain and the light/shadow gradients, **full-bleed
|
||||||
|
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp
|
||||||
|
layer, so the sacrificial outer ring is just more wood.
|
||||||
|
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
|
||||||
|
the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
|
||||||
|
scaled **0.75 about the icon centre** — **25 % smaller**, its «Э» ↔ ✻ arrangement
|
||||||
|
unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 %
|
||||||
|
safe zone** with margin to spare:
|
||||||
|
- «Э»: box height **40.4 %**, box center **51.8 % / 49.6 %**.
|
||||||
|
- ✻: outer diameter **9.9 %** (smaller than the master's), center **69.7 % / 67.0 %**,
|
||||||
|
tucked closer to the letter.
|
||||||
|
|
||||||
|
Everything else (palette, grain, gradients, star construction) is identical to the master.
|
||||||
|
|
||||||
|
## Variants in use
|
||||||
|
|
||||||
|
The **light** variant is the primary icon (launcher, favicon, apple-touch, PWA). The
|
||||||
|
**dark** variant is shipped in addition wherever the platform can pick by appearance
|
||||||
|
(e.g. an SVG favicon via `prefers-color-scheme`). Where a platform allows only one icon,
|
||||||
|
use **light**.
|
||||||
|
|
||||||
|
## Reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd assets/icons/brand
|
||||||
|
npm i opentype.js
|
||||||
|
# full master (iOS / PWA / favicon), either variant:
|
||||||
|
node build-icon.mjs --variant light --layer full > icon-light.svg
|
||||||
|
# Android adaptive layers:
|
||||||
|
node build-icon.mjs --variant light --layer background > icon-light-android-background.svg
|
||||||
|
node build-icon.mjs --variant light --layer foreground > icon-light-android-foreground.svg
|
||||||
|
# the construction figure:
|
||||||
|
node build-icon.mjs --variant light --scheme > icon-construction.svg
|
||||||
|
node render-png.mjs icon-light.svg icon-light.png 1024 # any size
|
||||||
|
```
|
||||||
|
|
||||||
|
`--layer` is `full` (default), `background`, or `foreground`. All outputs are committed
|
||||||
|
alongside the generator in [`../assets/icons/brand/`](../assets/icons/brand/).
|
||||||
|
|
||||||
|
## Masters
|
||||||
|
|
||||||
|
| | Light | Dark |
|
||||||
|
|-|-------|------|
|
||||||
|
| **Full** (iOS / PWA / favicon) |  |  |
|
||||||
|
| **Android background** |  |  |
|
||||||
|
| **Android foreground** |  |  |
|
||||||
@@ -57,6 +57,21 @@ for outside the store's own cash desk. Chips funded inside VK (Votes) may only b
|
|||||||
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
|
the VK context; Stars only inside Telegram; Robokassa-funded (`direct`) chips only outside
|
||||||
the stores. See §4.
|
the stores. See §4.
|
||||||
|
|
||||||
|
**Multi-shop direct rail (D42).** The `direct` rail routes to one Robokassa **merchant shop per
|
||||||
|
channel** — `web` and `android` (RuStore), `ios` later — chosen by the trusted `X-Platform` subtype;
|
||||||
|
every shop credits the one `direct` wallet (no per-channel wallet). This is merchant-account
|
||||||
|
separation for accounting / receipts only: the order records its `shop` (shown in the admin report),
|
||||||
|
and each shop's Result callback is verified by its own Password2 at `/pay/robokassa/result/<channel>`.
|
||||||
|
Standalone apps (Android/iOS) sign in by email only, so a direct purchase always has the D36 email
|
||||||
|
anchor (D43). Fiscalization (54-ФЗ via the Robokassa cabinet under one ИП) is a single source
|
||||||
|
regardless of the number of shops (D41).
|
||||||
|
|
||||||
|
**Payment availability kill switch (D45/D46).** An operator can disable purchases on a rail/channel
|
||||||
|
(`direct:web` / `direct:android` / `vk` / `telegram`) or for one account, live from `/_gm`, and the
|
||||||
|
user sees a localized reason on their next attempt (`payment_unavailable`, carried on the additive
|
||||||
|
`ExecuteResponse.message`). **Fail-open:** a rail with no status row is enabled. A per-account
|
||||||
|
`allow` override bypasses only this operational switch, never the security gates (D46).
|
||||||
|
|
||||||
## 3. Three operations, kept distinct
|
## 3. Three operations, kept distinct
|
||||||
|
|
||||||
Do not conflate these — they use different keys:
|
Do not conflate these — they use different keys:
|
||||||
|
|||||||
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||||
|
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||||
|
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||||
@@ -232,11 +234,53 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
- **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты,
|
- **D40. Финансовый отчёт per-user в `/_gm`** — балансы сегментов, платежи, траты,
|
||||||
гранты, возвраты, полная история — расширение существующей карточки
|
гранты, возвраты, полная история — расширение существующей карточки
|
||||||
(`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27).
|
(`UserDetailView`, `handlers_admin_console.go:343`). Плюс экспорт журнала (D27).
|
||||||
- **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД
|
- **D41. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП).**
|
||||||
(режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать
|
**Robokassa** (direct) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa
|
||||||
нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально
|
(провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате —
|
||||||
= не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK.
|
отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК Robokassa
|
||||||
*(Не юридическая консультация — точную схему НПД владелец сверяет с налоговой стороной.)*
|
(касса + ОФД + СНО). Код — опционально: слать детализированный `Receipt` + `Email`
|
||||||
|
покупателя (берём подтверждённый email-якорь D36) → чек с точным названием пакета и ставкой
|
||||||
|
по СНО; иначе — обобщённый дефолт-чек из кабинета. `Receipt` входит в подпись
|
||||||
|
(`MerchantLogin:OutSum:InvId:Receipt:Пароль#1`, URL-кодируется), одна позиция влезает в
|
||||||
|
текущий GET-redirect (POST-форма — фолбэк на длину URL). Источник чеков один — ИП/касса,
|
||||||
|
независимо от числа магазинов Robokassa. **VK** — процессит Голоса через налоговую сам.
|
||||||
|
**TG Stars** — вне рублёвого фискального контура (принимаем, чек не формируем). ОРД по
|
||||||
|
VK-рекламе — на стороне VK. *(Не юрконсультация — схему по 54-ФЗ/СНО владелец сверяет с
|
||||||
|
бухгалтером.)*
|
||||||
|
- **D42. Direct-рельс — несколько магазинов Robokassa = маршрутизация merchant-аккаунтов по
|
||||||
|
каналу при едином кошельке.** Кошелёк `direct` остаётся один. Отдельные магазины Robokassa
|
||||||
|
(web, android; ios — позже) различаются только кредами / Return-URL / учётом и **все
|
||||||
|
зачисляют в сегмент `direct`**. Модель кошельков, стенка трат и origin бенефитов не меняются.
|
||||||
|
Магазин выбирается по трастовому сигналу канала (`X-Platform` вида `<kind>/<subtype>`:
|
||||||
|
`direct/web`, `direct/android`), а не по подделываемому клиентскому полю; неизвестный подтип
|
||||||
|
→ магазин `web` (безопасный дефолт). Зачисление от выбора магазина не зависит (всегда
|
||||||
|
`direct`), поэтому ошибка маршрутизации влияет максимум на учёт, не на деньги.
|
||||||
|
- **D43. Standalone-приложения (Android, iOS) — вход только по email; покупка требует
|
||||||
|
email-якорь.** В нативной сборке доступны только guest + email: VK ID-логин (full-page
|
||||||
|
redirect на `id.vk.com`) не возвращается в Capacitor, TG Login Widget в WebView ненадёжен —
|
||||||
|
это уже действующая реальность сборки, не новое ограничение. Direct-покупка требует
|
||||||
|
подтверждённого email-якоря (D36) — он же адрес фискального чека (D41); гость не покупает.
|
||||||
|
Отдельный сегмент `apple` НЕ заводим: iOS-standalone — тот же `direct`-контекст, внешний гейт
|
||||||
|
(Robokassa) фондирует единый `direct`. Сегмент `apple` со стенкой (по образцу vk/tg)
|
||||||
|
понадобился бы только при Apple IAP (StoreKit) — отложено до решения по iOS; и identity-kind
|
||||||
|
`apple→direct`, и отдельный сегмент — аддитивны, без риска, делаются на месте.
|
||||||
|
- **D44. Канал платежа хранится на заказе для раздельного учёта/отчёта.** Заказ получает поле
|
||||||
|
`shop` (web/android/…) — аддитивная колонка. Используется в финансовом отчёте (D40) для
|
||||||
|
разбивки «из какого магазина/канала платёж». На зачисление и на сегмент кошелька не влияет
|
||||||
|
(всегда `direct`, D42).
|
||||||
|
- **D45. Рубильник платежей per-rail + локализованное сообщение.** Оператор выключает покупки на
|
||||||
|
рельсе/канале (`direct:web`/`direct:android`/`vk`/`telegram`) живьём из `/_gm` (таблица
|
||||||
|
`payments.rail_status`, редактируется на странице каталога). **Fail-open:** нет строки → рельс
|
||||||
|
включён (случайно не убить платежи). Выключенный рельс на попытке покупки возвращает
|
||||||
|
`payment_unavailable` + сообщение админа на языке юзера (RU/EN; пусто → встроенное «временно
|
||||||
|
недоступно»). Сообщение едет клиенту через аддитивное `ExecuteResponse.message` (envelope-слой,
|
||||||
|
frozen-contract-safe). Гейт ортогонален security-гейтам.
|
||||||
|
- **D46. Per-user override покупок (allow/deny/default).** На карточке юзера (`/_gm`) —
|
||||||
|
переопределение на аккаунт: `allow` (всегда разрешить), `deny` (всегда запретить), `default` (по
|
||||||
|
рельс-рубильнику). Хранится в `payments.account_payment_override` строкой только для не-default
|
||||||
|
(нет строки = default; снять = удалить строку). **`allow` обходит ТОЛЬКО ops-рубильник, НЕ
|
||||||
|
security-гейты** (D36 email-якорь, VK-iOS-фриз, trusted-платформа, min-client-version) — те
|
||||||
|
проверяются отдельно в CreateOrder.
|
||||||
|
|
||||||
## Заметки к оформлению документов
|
## Заметки к оформлению документов
|
||||||
|
|
||||||
@@ -252,10 +296,17 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
|
||||||
feedback-память `prefer-interview-mode` после plan mode.
|
feedback-память `prefer-interview-mode` после plan mode.
|
||||||
|
|
||||||
## Все развилки закрыты (D1-D41)
|
## Все развилки закрыты (D1-D46)
|
||||||
|
|
||||||
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
Интервью завершено. Дальше — оформление документов и реализация по релизам.
|
||||||
|
|
||||||
|
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
|
||||||
|
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
|
||||||
|
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
|
||||||
|
`direct` и входе email-only в standalone-приложениях (этап E10 в `PLAN.md`). Плюс D45-D46 —
|
||||||
|
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
|
||||||
|
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
|
||||||
|
|
||||||
## План внедрения (черновик PLAN.md — «слоями»)
|
## План внедрения (черновик PLAN.md — «слоями»)
|
||||||
|
|
||||||
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
|
||||||
|
|||||||
@@ -57,6 +57,21 @@
|
|||||||
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
в контексте VK; Stars — только внутри Telegram; Фишки от Robokassa (`direct`) — только вне
|
||||||
сторов. См. §4.
|
сторов. См. §4.
|
||||||
|
|
||||||
|
**Мультимагазинный direct-рельс (D42).** Рельс `direct` маршрутизирует в отдельный магазин
|
||||||
|
Robokassa **на канал** — `web` и `android` (RuStore), позже `ios` — по трастовому подтипу
|
||||||
|
`X-Platform`; каждый магазин зачисляет в единый кошелёк `direct` (отдельных кошельков на канал нет).
|
||||||
|
Это разделение merchant-аккаунтов только для учёта / чеков: заказ хранит свой `shop` (виден в
|
||||||
|
админ-отчёте), а Result-колбэк каждого магазина проверяется своим Password2 по
|
||||||
|
`/pay/robokassa/result/<channel>`. В standalone-приложениях (Android/iOS) вход только по email, поэтому
|
||||||
|
у direct-покупки всегда есть email-якорь D36 (D43). Фискализация (54-ФЗ через кабинет Robokassa под
|
||||||
|
одним ИП) — один источник независимо от числа магазинов (D41).
|
||||||
|
|
||||||
|
**Рубильник платежей (D45/D46).** Оператор выключает покупки на рельсе/канале (`direct:web` /
|
||||||
|
`direct:android` / `vk` / `telegram`) или для одного аккаунта, живьём из `/_gm`, и юзер на следующей
|
||||||
|
попытке видит локализованную причину (`payment_unavailable`, едет на аддитивном
|
||||||
|
`ExecuteResponse.message`). **Fail-open:** рельс без строки статуса — включён. Per-account `allow`
|
||||||
|
обходит только этот ops-рубильник, не security-гейты (D46).
|
||||||
|
|
||||||
## 3. Три операции, которые нельзя путать
|
## 3. Три операции, которые нельзя путать
|
||||||
|
|
||||||
Не смешивать — у них разные ключи:
|
Не смешивать — у них разные ключи:
|
||||||
|
|||||||