Compare commits

..

30 Commits

Author SHA1 Message Date
developer 39e03d22e4 Merge pull request 'release: v1.20.1 — real client IP to backend (last-login fix) + host UTC' (#265) from development into master 2026-07-14 09:44:43 +00:00
developer aaf162de40 Merge pull request 'release: v1.20.0 — offline-UX, catalog order + admin toggles, reward-config admin' (#263) from development into master 2026-07-14 09:02:54 +00:00
developer 5ce9f7e9b4 Merge pull request 'chore(release): promote development to master' (#258) from development into master 2026-07-13 22:21:59 +00:00
developer 3456a0b3ff Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master 2026-07-13 07:39:06 +00:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
116 changed files with 220 additions and 1923 deletions
+6 -6
View File
@@ -3,7 +3,7 @@
# a PR. It builds the native-flavoured SPA, bundles the offline dictionaries into the APK assets, and # 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). # 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 # Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline). # 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 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 # The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
@@ -144,7 +144,7 @@ jobs:
- name: Decode the release keystore - name: Decode the release keystore
id: keystore id: keystore
env: env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }} ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: | run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks" printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
@@ -152,16 +152,16 @@ jobs:
echo "keystore decoded -> signed release build" echo "keystore decoded -> signed release build"
else else
echo "file=" >> "$GITHUB_OUTPUT" echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_RUSTORE_KEYSTORE_BASE64 secret is not set — building an UNSIGNED release APK (not installable/publishable)" echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
fi fi
- name: Assemble the release APK - name: Assemble the release APK
working-directory: ui/android working-directory: ui/android
env: env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }} ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }} ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }} ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact - name: Upload the APK artifact
+17 -28
View File
@@ -62,20 +62,19 @@ client-only (no server change).
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code. Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
> ### ▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag) > ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep)
> >
> **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the > **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are
> release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The > DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
> RuStore MVP is now gated on **one owner-side item** — the **RuStore developer account** (owner going > the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
> ИП; RuStore updated its requirements) — plus the agent's **promote/tag → signed-build** chain. Also
> landed on `development` since: the payments **E10 multi-shop split** (#266) + **E11 kill switch**
> (#267), both dormant / fail-open (no bearing on the Android build), and the signing-secret rename
> `ANDROID_*` → **`ANDROID_RUSTORE_*`** (#268). Do not re-do the finished work or re-ask the locked
> decisions.
> >
> **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep > **Branches / PRs (current working branch: `feature/android-release-prep`).**
> hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the > - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
> signing-secret rename (#268). No open Android branch. > test contour). `/privacy/`, `/eula/`, `/offer/` are live there, bilingual RU/EN.
> - `feature/android-release-prep` — the **release-prep hygiene**, **PR #254 OPEN, green, NOT merged**
> (the owner chose to hold the merge). It is branched off `development`, so it also contains the
> legal-documents track. Merge #254 into `development` when ready — needs **owner approval** (the
> agent cannot self-approve).
> >
> **Done this session.** > **Done this session.**
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored > - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
@@ -114,12 +113,12 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
> 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and > 2. **Merge PR #254** → `development` (owner approval), then **promote `development` → `master`** and
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the > **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
> `X-Client-Version` header). > `X-Client-Version` header).
> 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`, > 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks
> PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific > -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea
> names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` / > **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` /
> `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated > `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated).
> the cert is pinned at the first RuStore upload). Google Play, later, would use its own > The AGENT provides the exact commands on request. Without the secrets the workflow builds an
> `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key. > UNSIGNED APK (a dry run still proves the pipeline).
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates > 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
> publication, not the build. > publication, not the build.
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via > 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
@@ -133,16 +132,6 @@ Kept current as parts land so a fresh session resumes without re-deriving. Verif
> 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to > 8. **Upload to RuStore.** OWNER. After publication, set the `ANDROID_RUSTORE_URL` Gitea variable to
> the store deep-link so the update button works on future gated releases (empty until then — the > the store deep-link so the update button works on future gated releases (empty until then — the
> gate is dormant in the MVP). > gate is dormant in the MVP).
>
> **Next tracks (design in a future session — interview the owner first).**
> - **Native notifications (push).** Promote FCM/push from "Out of scope" to a real track: a Capacitor
> push plugin, the Android send path (FCM — but **check RuStore's own push service vs FCM on RuStore
> devices**, since Google Play Services may be absent), token registration, and reusing the existing
> server notification dispatcher. Owner will bring specifics.
> - **A test APK for the emulator** (owner asked): the signed APK comes from `android-build` (needs the
> promote/tag chain, step 2). A quick **debug** APK is buildable without the release chain if the host
> has the Android SDK + JDK 21 (`pnpm build` → `cap sync` → `assembleDebug`).
> - Possibly other native polish (owner to flag).
- **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated - **A. Capacitor scaffolding — ✅ DONE & verified (2026-07-12).** Capacitor 8 native project generated
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
+1 -121
View File
@@ -39,13 +39,10 @@ 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. E10 splits the `direct` rail into per-channel Robokassa shops + ИП parallel). E9 is future.
fiscalization (post-launch, backend-first).
--- ---
@@ -895,123 +892,6 @@ 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), D42D44.
**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 (B1B3, 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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+2 -8
View File
@@ -19,11 +19,6 @@ const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png')); const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png')); const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
const MONO = uri(join(DIR, 'android-monochrome.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)] // 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 D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
@@ -43,9 +38,8 @@ for (const [d, [fg, lg]] of Object.entries(D)) {
writeFileSync(join(dir, 'ic_launcher_background.png'), await shot(im(BG, fg), fg, false)); 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_foreground.png'), await shot(im(FG, fg), fg, true));
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, 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 const comp = `<div style="position:relative;width:${lg}px;height:${lg}px">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
// composite, circle-clipped (the master's corner ✻ would be clipped by the round mask). writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
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>`; 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)); writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
console.log('mipmap-' + d, 'ok'); console.log('mipmap-' + d, 'ok');
+2 -6
View File
@@ -33,12 +33,8 @@ const SHADE = 0.15; // black, this alpha bottom-left -> transp
// The mark, placed for the standalone master (bottom-right biased, full-bleed): // 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] }; 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 // 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 ✻). // 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 const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
// 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 = { const PALETTE = {
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' }, light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+2 -7
View File
@@ -49,14 +49,9 @@ COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console # mounted at /opt/dawg inherits this ownership on first use, so the admin console
# can write new version subdirectories at runtime. The volume preserves uploaded # can write new version subdirectories at runtime. The volume preserves uploaded
# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5). # versions across deploys and, once seeded, is not re-seeded — so after bootstrap
# every dictionary change goes through the console, not a rebuild (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg
# A second, UNSHADOWED copy of the build's DAWGs: the /opt/dawg volume mount hides the
# image's copy there, so this is where engine.DeliverVersion reads the build version from
# to add it (add-only) to the volume on boot — the deploy-delivers path that lets a bumped
# BACKEND_DICT_VERSION go live for new games without an admin upload (ARCHITECTURE.md §5).
COPY --from=dawg --chown=65532:65532 /dawg /opt/dawg-seed
ENV BACKEND_DICT_DIR=/opt/dawg ENV BACKEND_DICT_DIR=/opt/dawg
ENV BACKEND_DICT_SEED_DIR=/opt/dawg-seed
ENV BACKEND_DICT_VERSION=${DICT_VERSION} ENV BACKEND_DICT_VERSION=${DICT_VERSION}
ENTRYPOINT ["/usr/local/bin/backend"] ENTRYPOINT ["/usr/local/bin/backend"]
+4 -11
View File
@@ -111,12 +111,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
} }
logger.Info("database migrations applied") logger.Info("database migrations applied")
// Deliver the build's dictionary version onto the persistent volume if a redeploy
// bumped it (add-only; old versions in-flight games pin are untouched), so the new
// dictionary is resident and can be activated below without an admin upload.
if err := engine.DeliverVersion(cfg.Game.DictDir, cfg.Game.DictSeedDir, cfg.Game.DictVersion); err != nil {
return fmt.Errorf("deliver dictionary version: %w", err)
}
registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion) registry, err := engine.OpenWithVersions(cfg.Game.DictDir, cfg.Game.DictVersion)
if err != nil { if err != nil {
return fmt.Errorf("load dictionaries: %w", err) return fmt.Errorf("load dictionaries: %w", err)
@@ -124,7 +118,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
defer func() { _ = registry.Close() }() defer func() { _ = registry.Close() }()
logger.Info("dictionaries loaded", logger.Info("dictionaries loaded",
zap.String("dir", cfg.Game.DictDir), zap.String("dir", cfg.Game.DictDir),
zap.String("seed_dir", cfg.Game.DictSeedDir),
zap.String("version", cfg.Game.DictVersion)) zap.String("version", cfg.Game.DictVersion))
// Admin console: an optional backend client to the Telegram connector // Admin console: an optional backend client to the Telegram connector
@@ -154,10 +147,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
accounts := account.NewStore(db) accounts := account.NewStore(db)
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account")) accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger) games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
// Reconcile the active dictionary version with the registry: the build version // Reconcile the persisted active dictionary version with the registry: a
// (delivered above) becomes active so a redeploy that bumped it goes live for new // version activated through the admin console (and written to the dictionary
// games, except a newer version installed out-of-band through the admin console, // volume) is adopted again after a restart; otherwise the configured seed
// which is not downgraded by a restart (docs/ARCHITECTURE.md §5). // version is kept and persisted (docs/ARCHITECTURE.md §5).
if err := games.InitActiveVersion(ctx); err != nil { if err := games.InitActiveVersion(ctx); err != nil {
return fmt.Errorf("init active dictionary version: %w", err) return fmt.Errorf("init active dictionary version: %w", err)
} }
+5 -19
View File
@@ -10,7 +10,6 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2"
"strings" "strings"
"time" "time"
@@ -526,13 +525,8 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
return created, nil return created, nil
} }
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a // guestDisplayName is the display name stamped on a freshly provisioned guest.
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a const guestDisplayName = "Guest"
// random match the other player sees a distinct name rather than every guest reading a bare
// "Guest". Not an identifier and not unique (collisions are harmless — it is a label only).
func guestDisplayName() string {
return fmt.Sprintf("Guest%06d", rand.IntN(1_000_000))
}
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying // ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both // no identity, flagged is_guest, so it can hold a session and a game seat (both
@@ -540,7 +534,7 @@ func guestDisplayName() string {
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ // and history. Guests are not reused — each bootstrap mints a new account. browserTZ
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling // (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed. // back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) { func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
accountID, err := uuid.NewV7() accountID, err := uuid.NewV7()
if err != nil { if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err) return Account{}, fmt.Errorf("account: new guest id: %w", err)
@@ -549,17 +543,9 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string)
if tz == "" { if tz == "" {
tz = "UTC" tz = "UTC"
} }
// Seed the interface language from the client's detected locale (validated to a supported
// one), so a fresh guest's language-dependent server content — the ad banner, bot messages —
// is right from first contact rather than the 'en' column default until the client's later
// language reconcile catches up. An unsupported or absent code keeps the 'en' default.
lang := supportedLanguage(language)
if lang == "" {
lang = "en"
}
stmt := table.Accounts. stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName(), true, tz, lang). VALUES(accountID, guestDisplayName, true, tz).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
@@ -30,18 +30,6 @@
<div><button type="submit">Save</button></div> <div><button type="submit">Save</button></div>
</form> </form>
</section> </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 &ldquo;temporarily unavailable&rdquo;. Fail-open: a rail you never touch stays on. A per-user &ldquo;allow&rdquo; 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> &mdash; 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> <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">
@@ -72,24 +72,14 @@
{{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>Shop</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>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>{{.Shop}}</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>{{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>
+2 -18
View File
@@ -198,9 +198,6 @@ 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
@@ -235,8 +232,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 / direct-rail shop it references (empty when none), the raw // delta, the product / order / provider it references (empty when none), the raw snapshot JSON and
// snapshot JSON and the pre-formatted time. // the pre-formatted time.
type LedgerRow struct { type LedgerRow struct {
Kind string Kind string
Source string Source string
@@ -245,7 +242,6 @@ 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
} }
@@ -690,18 +686,6 @@ type CatalogView struct {
RewardPayout int RewardPayout int
RewardDailyCap int RewardDailyCap int
RewardHourlyCap 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
+11 -34
View File
@@ -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 — one merchant shop per channel // Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
// (D42). An empty set leaves the direct order and Result-callback endpoints unregistered. // leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Shops Robokassa robokassa.Config
} }
// Defaults applied when the corresponding environment variable is unset. // Defaults applied when the corresponding environment variable is unset.
@@ -106,7 +106,6 @@ func Load() (Config, error) {
gm := game.DefaultConfig() gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir) gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion) gm.DictVersion = envOr("BACKEND_DICT_VERSION", gm.DictVersion)
gm.DictSeedDir = envOr("BACKEND_DICT_SEED_DIR", gm.DictSeedDir)
if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil { if gm.TimeoutSweepInterval, err = envDuration("BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL", gm.TimeoutSweepInterval); err != nil {
return Config{}, err return Config{}, err
} }
@@ -158,19 +157,11 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"), AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
} }
// Robokassa direct rail: one merchant shop per channel (D42). The legacy single-shop vars seed robo := robokassa.Config{
// the web channel so existing deploys keep working; the per-channel vars add the rest. A shop MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
// with no MerchantLogin is dropped (the rail stays dormant when none is configured). Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
shops := robokassa.Shops{} Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
web := robokassaShop("BACKEND_ROBOKASSA_WEB") IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
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{
@@ -190,7 +181,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: shops, Robokassa: robo,
} }
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
@@ -243,10 +234,8 @@ 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)
} }
} }
for channel, shop := range c.Robokassa { if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
if shop.Password1 == "" || shop.Password2 == "" { return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
return fmt.Errorf("config: robokassa shop %q: password1 and password2 must be set when its merchant login is", channel)
}
} }
return nil return nil
} }
@@ -287,15 +276,3 @@ 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",
}
}
-92
View File
@@ -1,92 +0,0 @@
package engine
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
// DeliverVersion makes the build's dictionary version resident on the persistent
// dictionary volume without disturbing the versions in-flight games already pin.
//
// The volume is seeded from the image once and never re-seeded, and the image's
// /opt/dawg is shadowed by that volume mount, so a redeploy that bumped
// BACKEND_DICT_VERSION cannot otherwise make the new DAWGs reachable at runtime.
// The image therefore keeps an unshadowed read-only copy at seedDir, and this
// copies it into dictDir/<version>/ when the version is not already present —
// neither the flat seed's own recorded label nor an existing version
// subdirectory. It is add-only and idempotent: the flat seed and any prior admin
// uploads are never touched, so old games keep the version they pin while the new
// one becomes resident and activatable (see game.Service.InitActiveVersion). A
// blank seedDir disables delivery (the pre-existing seed-only behaviour).
func DeliverVersion(dictDir, seedDir, version string) error {
if seedDir == "" || version == "" {
return nil
}
target := filepath.Join(dictDir, version)
if _, err := os.Stat(target); err == nil {
return nil // already delivered on a prior boot
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("engine: stat delivered dictionary %s: %w", target, err)
}
// A fresh volume is populated from the image and recorded as this version by the
// marker: it is the flat dir, so there is nothing to deliver. resolveSeedVersion
// records the label on first use, matching OpenWithVersions.
seed, err := resolveSeedVersion(dictDir, version)
if err != nil {
return err
}
if seed == version {
return nil
}
// Stage into a dot-prefixed directory (skipped by OpenWithVersions' version scan)
// then rename, so a crash mid-copy never leaves a half-populated version subdir.
staging := filepath.Join(dictDir, ".staging-deliver-"+version)
if err := os.RemoveAll(staging); err != nil {
return fmt.Errorf("engine: clear deliver staging %s: %w", staging, err)
}
if err := os.MkdirAll(staging, 0o755); err != nil {
return fmt.Errorf("engine: create deliver staging %s: %w", staging, err)
}
for _, file := range dictFiles {
src := filepath.Join(seedDir, file)
if _, err := os.Stat(src); errors.Is(err, os.ErrNotExist) {
continue // a variant absent from the seed is simply absent under this version
} else if err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: stat seed dawg %s: %w", src, err)
}
if err := copyFile(src, filepath.Join(staging, file)); err != nil {
_ = os.RemoveAll(staging)
return err
}
}
if err := os.Rename(staging, target); err != nil {
_ = os.RemoveAll(staging)
return fmt.Errorf("engine: publish delivered dictionary %s: %w", target, err)
}
return nil
}
// copyFile copies a single file, truncating the destination.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("engine: open %s: %w", src, err)
}
defer func() { _ = in.Close() }()
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return fmt.Errorf("engine: create %s: %w", dst, err)
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return fmt.Errorf("engine: copy %s -> %s: %w", src, dst, err)
}
if err := out.Close(); err != nil {
return fmt.Errorf("engine: finalise %s: %w", dst, err)
}
return nil
}
-68
View File
@@ -1,68 +0,0 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestDeliverVersion(t *testing.T) {
// A blank seed directory disables delivery (the pre-existing seed-only behaviour).
if err := DeliverVersion(t.TempDir(), "", "v1.3.1"); err != nil {
t.Fatalf("blank seedDir: %v", err)
}
// An existing volume flat-seeded as v1.3.0 gets v1.3.1 delivered as a subdirectory,
// add-only: the flat seed's marker is left intact and the new version's DAWGs land.
dict := t.TempDir()
seed := t.TempDir()
if err := os.WriteFile(filepath.Join(dict, seedMarkerFile), []byte("v1.3.0\n"), 0o644); err != nil {
t.Fatal(err)
}
for _, f := range dictFiles {
if err := os.WriteFile(filepath.Join(seed, f), []byte("dawg:"+f), 0o644); err != nil {
t.Fatal(err)
}
}
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("deliver v1.3.1: %v", err)
}
for _, f := range dictFiles {
got, err := os.ReadFile(filepath.Join(dict, "v1.3.1", f))
if err != nil {
t.Fatalf("delivered %s: %v", f, err)
}
if string(got) != "dawg:"+f {
t.Errorf("delivered %s = %q, want the seed bytes", f, got)
}
}
if m, _ := os.ReadFile(filepath.Join(dict, seedMarkerFile)); string(m) != "v1.3.0\n" {
t.Errorf("flat seed marker relabelled to %q (delivery must be add-only)", m)
}
// Idempotent: a second call is a no-op and leaves no staging directory behind.
if err := DeliverVersion(dict, seed, "v1.3.1"); err != nil {
t.Fatalf("re-deliver v1.3.1: %v", err)
}
if entries, _ := os.ReadDir(dict); hasStaging(entries) {
t.Errorf("a staging directory survived delivery")
}
// On a fresh directory the build version becomes the flat seed, so nothing is delivered
// as a redundant subdirectory.
fresh := t.TempDir()
if err := DeliverVersion(fresh, seed, "v1.3.1"); err != nil {
t.Fatalf("fresh deliver: %v", err)
}
if _, err := os.Stat(filepath.Join(fresh, "v1.3.1")); !os.IsNotExist(err) {
t.Errorf("fresh volume got a redundant v1.3.1 subdir; it should be the flat seed")
}
}
func hasStaging(entries []os.DirEntry) bool {
for _, e := range entries {
if len(e.Name()) >= len(".staging") && e.Name()[:len(".staging")] == ".staging" {
return true
}
}
return false
}
+5 -5
View File
@@ -25,11 +25,11 @@ const seedMarkerFile = ".seed_version"
// BACKEND_DICT_VERSION) and return it — the seed of a fresh volume; // BACKEND_DICT_VERSION) and return it — the seed of a fresh volume;
// - already-seeded directory: return the recorded marker and ignore bootVersion. // - already-seeded directory: return the recorded marker and ignore bootVersion.
// //
// So a later build seed never relabels the already-seeded flat bytes — which would void // So bumping the build seed on a live volume is a harmless no-op (it only takes
// games pinned to the prior label and mis-serve new ones. The new build version is instead // effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion), // which would void games pinned to the prior label and mis-serve new ones. New games
// so a redeploy still moves new games to it while old games keep the flat label. New games // still pin the active version (DB-persisted, set by the admin console), which is the
// pin the active version (DB-persisted); a console upload can also set it out-of-band. // real way a running contour moves to a new release.
// //
// A directory that cannot be written makes the first record fail; that also breaks // A directory that cannot be written makes the first record fail; that also breaks
// the admin console (which writes version subdirectories here), so the error is // the admin console (which writes version subdirectories here), so the error is
-7
View File
@@ -18,13 +18,6 @@ type Config struct {
// DictVersion labels the dictionary version new games pin. Sourced from // DictVersion labels the dictionary version new games pin. Sourced from
// BACKEND_DICT_VERSION. // BACKEND_DICT_VERSION.
DictVersion string DictVersion string
// DictSeedDir holds the image's read-only copy of the build's DictVersion DAWGs,
// on a path the persistent DictDir volume does NOT shadow. On boot the backend
// delivers this version into DictDir/<DictVersion>/ if absent (add-only), so a
// redeploy that bumped DICT_VERSION makes the new dictionary resident and activatable
// without disturbing the versions in-flight games already pin. Sourced from
// BACKEND_DICT_SEED_DIR; empty disables delivery (the pre-existing seed-only behaviour).
DictSeedDir string
// TimeoutSweepInterval is how often the sweeper scans for overdue turns. // TimeoutSweepInterval is how often the sweeper scans for overdue turns.
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL. // Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
TimeoutSweepInterval time.Duration TimeoutSweepInterval time.Duration
+8 -18
View File
@@ -232,31 +232,21 @@ func (svc *Service) ActiveVersion() string {
} }
// InitActiveVersion reconciles the persisted active dictionary version with the // InitActiveVersion reconciles the persisted active dictionary version with the
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered // registry at startup. If a version was persisted and is resident, it becomes the
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build // active version; otherwise the configured seed version is kept and persisted as
// version thus activates it for new games without any admin step, while old games keep // the initial active version. Call once during wiring, before serving traffic.
// the version they pin. The one exception is a version installed out-of-band through
// the admin console that is NEWER than the build version and still resident — it is
// kept, so a restart on an older image never downgrades a hotfix. A build version that
// is not resident (delivery disabled or a partial state) falls back to a resident
// persisted version. The chosen version is persisted so the admin console reflects it.
// Call once during wiring, before serving traffic.
func (svc *Service) InitActiveVersion(ctx context.Context) error { func (svc *Service) InitActiveVersion(ctx context.Context) error {
persisted, ok, err := svc.store.GetActiveDictVersion(ctx) persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
if err != nil { if err != nil {
return err return err
} }
target := svc.activeVersion() // the build seed version (config.DictVersion) if ok && svc.versionResident(persisted) {
if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
target = persisted // a newer out-of-band install wins over the build version
}
if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
target = persisted // the build version is unloadable — keep a resident one
}
svc.verMu.Lock() svc.verMu.Lock()
svc.version = target svc.version = persisted
svc.verMu.Unlock() svc.verMu.Unlock()
return svc.store.SetActiveDictVersion(ctx, target) return nil
}
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
} }
// SetActiveVersion records version as the active dictionary version, persisting it // SetActiveVersion records version as the active dictionary version, persisting it
-45
View File
@@ -1,45 +0,0 @@
package game
import (
"strconv"
"strings"
)
// compareDictVersions orders two dictionary version labels of the shape "vMAJOR.MINOR.PATCH"
// numerically, returning -1, 0 or +1 as a is less than, equal to or greater than b. Labels
// that do not parse fall back to a byte comparison, so the ordering is always total (a mixed
// or malformed pair never crashes the boot-time active-version reconcile).
func compareDictVersions(a, b string) int {
pa, oka := parseDictVersion(a)
pb, okb := parseDictVersion(b)
if !oka || !okb {
return strings.Compare(a, b)
}
for i := range pa {
if pa[i] != pb[i] {
if pa[i] < pb[i] {
return -1
}
return 1
}
}
return 0
}
// parseDictVersion parses a "vMAJOR.MINOR.PATCH" label into its three numeric parts, reporting
// whether it matched that shape.
func parseDictVersion(v string) ([3]int, bool) {
parts := strings.Split(strings.TrimPrefix(v, "v"), ".")
if len(parts) != 3 {
return [3]int{}, false
}
var out [3]int
for i, p := range parts {
n, err := strconv.Atoi(p)
if err != nil {
return [3]int{}, false
}
out[i] = n
}
return out, true
}
-37
View File
@@ -1,37 +0,0 @@
package game
import "testing"
func TestCompareDictVersions(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"v1.3.0", "v1.3.1", -1},
{"v1.3.1", "v1.3.0", 1},
{"v1.3.1", "v1.3.1", 0},
{"v1.3.9", "v1.3.10", -1}, // numeric, not lexical
{"v1.10.0", "v1.9.0", 1},
{"v2.0.0", "v1.9.9", 1},
{"1.3.1", "v1.3.1", 0}, // the leading v is optional
// Non-semver falls back to a byte comparison, keeping the ordering total.
{"weird", "weird", 0},
{"a", "b", -1},
}
for _, c := range cases {
if got := compareDictVersions(c.a, c.b); sign(got) != c.want {
t.Errorf("compareDictVersions(%q, %q) = %d, want sign %d", c.a, c.b, got, c.want)
}
}
}
func sign(n int) int {
switch {
case n < 0:
return -1
case n > 0:
return 1
default:
return 0
}
}
+3 -23
View File
@@ -5,7 +5,6 @@ package inttest
import ( import (
"context" "context"
"errors" "errors"
"regexp"
"testing" "testing"
"time" "time"
@@ -230,40 +229,21 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone) t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
} }
// A guest is seeded its detected offset and interface language; an empty offset keeps the // A guest is seeded its detected offset; an empty one keeps the UTC default.
// UTC default and an empty/unsupported language keeps the 'en' default. guest, err := store.ProvisionGuest(ctx, "-05:30")
guest, err := store.ProvisionGuest(ctx, "-05:30", "ru")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
if guest.TimeZone != "-05:30" { if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone) t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
} }
if guest.PreferredLanguage != "ru" { plainGuest, err := store.ProvisionGuest(ctx, "")
t.Errorf("guest PreferredLanguage = %q, want the seeded ru", guest.PreferredLanguage)
}
// A guest's display name is "Guest" + a random six-digit suffix (distinct to opponents).
if m, _ := regexp.MatchString(`^Guest\d{6}$`, guest.DisplayName); !m {
t.Errorf("guest DisplayName = %q, want Guest + 6 digits", guest.DisplayName)
}
plainGuest, err := store.ProvisionGuest(ctx, "", "")
if err != nil { if err != nil {
t.Fatalf("provision plain guest: %v", err) t.Fatalf("provision plain guest: %v", err)
} }
if plainGuest.TimeZone != "UTC" { if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone) t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
} }
if plainGuest.PreferredLanguage != "en" {
t.Errorf("plain guest PreferredLanguage = %q, want en default", plainGuest.PreferredLanguage)
}
// An unsupported locale falls back to the 'en' default rather than persisting a junk value.
frGuest, err := store.ProvisionGuest(ctx, "", "fr-FR")
if err != nil {
t.Fatalf("provision fr guest: %v", err)
}
if frGuest.PreferredLanguage != "en" {
t.Errorf("unsupported-locale guest PreferredLanguage = %q, want en default", frGuest.PreferredLanguage)
}
} }
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram // TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
+1 -1
View File
@@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) {
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "", "") guest, err := store.ProvisionGuest(ctx, "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
@@ -103,31 +103,14 @@ func TestDictionaryUpdateFlow(t *testing.T) {
t.Errorf("installed dawg missing on disk: %v", err) t.Errorf("installed dawg missing on disk: %v", err)
} }
// The choice survives a restart on the SAME build: an out-of-band admin install newer // The choice survives a restart: a fresh service over the same DB and registry
// than the build version is not downgraded (a restart on an older image keeps the hotfix). // re-adopts the persisted active version.
restarted := newGameServiceOn(dir, "v1.0.0", reg) restarted := newGameServiceOn(dir, "v1.0.0", reg)
if err := restarted.InitActiveVersion(ctx); err != nil { if err := restarted.InitActiveVersion(ctx); err != nil {
t.Fatalf("restart init: %v", err) t.Fatalf("restart init: %v", err)
} }
if got := restarted.ActiveVersion(); got != "v9.9.9" { if got := restarted.ActiveVersion(); got != "v9.9.9" {
t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got) t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
}
// A redeploy that delivered a build version NEWER than the out-of-band one activates it
// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
t.Fatalf("make v10.0.0 resident: %v", err)
}
deployed := newGameServiceOn(dir, "v10.0.0", reg)
if err := deployed.InitActiveVersion(ctx); err != nil {
t.Fatalf("deploy init: %v", err)
}
if got := deployed.ActiveVersion(); got != "v10.0.0" {
t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
}
// Restore the out-of-band active version so the assertions below still read v9.9.9.
if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
t.Fatalf("restore active: %v", err)
} }
// New games pin the new version; the in-progress game keeps its own, still resident. // New games pin the new version; the in-progress game keeps its own, still resident.
+1 -1
View File
@@ -431,7 +431,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
store := account.NewStore(testDB) store := account.NewStore(testDB)
mailer := &capturingMailer{} mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru") svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "", "") guest, err := store.ProvisionGuest(ctx, "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
+1 -1
View File
@@ -154,7 +154,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id. // provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID { func provisionGuest(t *testing.T) uuid.UUID {
t.Helper() t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "") acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
+9 -39
View File
@@ -347,10 +347,8 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
} }
} }
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that // TestAccountLinkGuestInversion merges a guest initiator into the durable account
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable // that owns the email: the durable account wins and a fresh session is minted.
// account wins and a fresh session is minted for it (the client adopts the switch and lands on
// the durable account as if it simply logged in).
func TestAccountLinkGuestInversion(t *testing.T) { func TestAccountLinkGuestInversion(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
@@ -366,18 +364,17 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err) t.Fatalf("request: %v", err)
} }
code := sixDigit.FindString(mailer.lastBody) code := sixDigit.FindString(mailer.lastBody)
confirm, err := links.ConfirmEmail(ctx, guest, email, code) if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
if err != nil {
t.Fatalf("confirm: %v", err) t.Fatalf("confirm: %v", err)
} }
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline. merge, err := links.MergeEmail(ctx, guest, email, code)
if !confirm.Merged || confirm.MergeRequired { if err != nil {
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm) t.Fatalf("merge: %v", err)
} }
if confirm.Merge.PrimaryID != durable { if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable) t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
} }
if confirm.Merge.SwitchedToken == "" { if merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token") t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
} }
if mergedInto(t, guest) != durable { if mergedInto(t, guest) != durable {
@@ -388,33 +385,6 @@ func TestAccountLinkGuestInversion(t *testing.T) {
} }
} }
// TestAccountLinkGuestAutoMergeActiveGameConflict guards that a guest's auto-merge is REFUSED
// (not silently swallowed) at the confirm step when the guest and the durable account share an
// active game: the caller surfaces ErrActiveGameConflict (mapped to a clear "finish that game
// first" message) rather than seating one player against themselves.
func TestAccountLinkGuestAutoMergeActiveGameConflict(t *testing.T) {
ctx := context.Background()
mailer := &capturingMailer{}
links := newLinkService(mailer)
durable := provisionAccount(t)
email := "conflict-" + uuid.NewString() + "@example.com"
bindEmailIdentity(t, durable, email)
guest := provisionGuest(t)
seatGame(t, []uuid.UUID{durable, guest}, 24*time.Hour) // an active game the two share
if err := links.RequestEmail(ctx, guest, email); err != nil {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != accountmerge.ErrActiveGameConflict {
t.Fatalf("confirm = %v, want ErrActiveGameConflict surfaced by the auto-merge", err)
}
if mergedInto(t, guest) != uuid.Nil {
t.Error("a refused auto-merge must not tombstone the guest")
}
}
// TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and // TestAccountLinkFreeVK binds a free VK identity (gateway-validated, no code) and
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case. // promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) { func TestAccountLinkFreeVK(t *testing.T) {
@@ -1,83 +0,0 @@
//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")
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("provision robot: %v", err) t.Fatalf("provision robot: %v", err)
} }
guest, err := st.ProvisionGuest(ctx, "", "") guest, err := st.ProvisionGuest(ctx, "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
+6 -32
View File
@@ -31,39 +31,13 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions} return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
} }
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked, // ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge // MergeRequired is set; SecondaryID is the account to be retired when a merge is
// is required (the caller renders an irreversible-merge confirmation from it). Merged is // required (the caller renders an irreversible-merge confirmation from it).
// set when the caller was a guest, so the merge ran inline (see confirmOrAutoMerge) and
// Merge carries its result (the switched-session token for the surviving durable account).
type ConfirmResult struct { type ConfirmResult struct {
Linked bool Linked bool
MergeRequired bool MergeRequired bool
SecondaryID uuid.UUID SecondaryID uuid.UUID
Merged bool
Merge MergeResult
}
// confirmOrAutoMerge decides a required merge at the confirm step. A durable caller gets
// the explicit MergeRequired confirmation (consolidating two real accounts is irreversible
// and consequential — the user must see it). A GUEST caller does not: by the guest-primary
// rule the durable other account survives and the ephemeral guest is retired, folding its
// games/wallet/stats in — from the user's side it is simply "logged into my account", so the
// merge runs inline and the client just adopts the switched session. The active-game guard can
// still refuse (accountmerge.ErrActiveGameConflict), surfaced to the caller unchanged.
func (s *Service) confirmOrAutoMerge(ctx context.Context, callerID, owner uuid.UUID) (ConfirmResult, error) {
caller, err := s.accounts.GetByID(ctx, callerID)
if err != nil {
return ConfirmResult{}, err
}
if !caller.IsGuest {
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
}
res, err := s.merge(ctx, callerID, owner)
if err != nil {
return ConfirmResult{}, err
}
return ConfirmResult{Merged: true, Merge: res}, nil
} }
// MergeResult reports a completed merge. PrimaryID is the surviving account. // MergeResult reports a completed merge. PrimaryID is the surviving account.
@@ -93,7 +67,7 @@ func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email,
} }
return ConfirmResult{Linked: true}, nil return ConfirmResult{Linked: true}, nil
} }
return s.confirmOrAutoMerge(ctx, accountID, owner) return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
} }
// MergeEmail re-verifies the code and merges the address's account into the // MergeEmail re-verifies the code and merges the address's account into the
@@ -129,7 +103,7 @@ func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, exter
if owner == callerID { if owner == callerID {
return ConfirmResult{Linked: true}, nil return ConfirmResult{Linked: true}, nil
} }
return s.confirmOrAutoMerge(ctx, callerID, owner) return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
} }
// MergeTelegram merges the account owning a gateway-validated Telegram identity // MergeTelegram merges the account owning a gateway-validated Telegram identity
@@ -176,7 +150,7 @@ func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID
if owner == callerID { if owner == callerID {
return ConfirmResult{Linked: true}, nil return ConfirmResult{Linked: true}, nil
} }
return s.confirmOrAutoMerge(ctx, callerID, owner) return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
} }
// MergeVK merges the account owning a gateway-validated VK identity into the caller's // MergeVK merges the account owning a gateway-validated VK identity into the caller's
-107
View File
@@ -1,107 +0,0 @@
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)
}
@@ -1,40 +0,0 @@
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)
}
}
@@ -1,63 +0,0 @@
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,7 +46,6 @@ 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
@@ -54,16 +53,6 @@ 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).
+1 -3
View File
@@ -42,8 +42,7 @@ 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; Shop is the direct-rail merchant channel the referenced order used (empty for // the column is NULL; Snapshot is the raw purchase/refund JSON (empty when none).
// 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
@@ -53,7 +52,6 @@ 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
} }
@@ -1,96 +0,0 @@
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
}
+2 -3
View File
@@ -151,7 +151,6 @@ 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.
@@ -160,12 +159,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.Shop, table.Orders.CreatedAt, table.Orders.UpdatedAt,
).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, o.shop, now, now,
) )
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)
@@ -74,39 +74,9 @@ 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,5 +25,4 @@ type Orders struct {
ProviderPaymentID *string ProviderPaymentID *string
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
Shop string
} }
@@ -29,7 +29,6 @@ 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
@@ -83,10 +82,9 @@ 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")
ShopColumn = postgres.StringColumn("shop") allColumns = postgres.ColumnList{OrderIDColumn, 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} mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, PlatformColumn, ProductIDColumn, ExpectedAmountColumn, CurrencyColumn, OriginColumn, StatusColumn, ProviderColumn, ProviderPaymentIDColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn} defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn}
defaultColumns = postgres.ColumnList{StatusColumn, CreatedAtColumn, UpdatedAtColumn, ShopColumn}
) )
return ordersTable{ return ordersTable{
@@ -105,7 +103,6 @@ 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,
@@ -1,13 +0,0 @@
-- 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;
@@ -1,26 +0,0 @@
-- 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;
-54
View File
@@ -1,54 +0,0 @@
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
}
-52
View File
@@ -1,52 +0,0 @@
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")
}
}
+1 -1
View File
@@ -89,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.Configured() { if s.robokassa.MerchantLogin != "" {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult) s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
} }
} }
@@ -51,16 +51,7 @@ func (s *Server) consoleCatalog(c *gin.Context) {
s.consoleError(c, err) s.consoleError(c, err)
return 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} 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))
} }
@@ -81,23 +72,6 @@ func (s *Server) consoleSetReward(c *gin.Context) {
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack) 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}
@@ -61,7 +61,6 @@ 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)
@@ -114,7 +113,6 @@ func (s *Server) registerConsole(router *gin.Engine) {
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/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)
@@ -463,9 +461,6 @@ 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)
} }
@@ -487,54 +482,13 @@ 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, Shop: e.Shop, Snapshot: e.Snapshot, Product: e.ProductID, Order: e.OrderID, Provider: e.Provider, 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 {
+4 -8
View File
@@ -160,20 +160,16 @@ type guestAuthRequest struct {
// Subtype is the client-reported device family (ios/android/web) of this direct // Subtype is the client-reported device family (ios/android/web) of this direct
// (web/native) session; there is no external signer, so it is recorded best-effort. // (web/native) session; there is no external signer, so it is recorded best-effort.
Subtype string `json:"subtype"` Subtype string `json:"subtype"`
// Language is the client's detected interface locale, seeding the fresh guest's
// interface language (best-effort; an unsupported/absent value keeps the 'en' default).
Language string `json:"language"`
} }
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session, // handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
// seeding its time zone from the optional detected browser offset and its interface // seeding its time zone from the optional detected browser offset.
// language from the optional detected locale.
func (s *Server) handleGuestAuth(c *gin.Context) { func (s *Server) handleGuestAuth(c *gin.Context) {
// The body is optional: an absent or malformed one simply yields no time-zone/language seed // The body is optional: an absent or malformed one simply yields no time-zone seed
// (the account keeps the UTC / 'en' defaults), so a bind error must not fail the bootstrap. // (the account keeps the UTC default), so a bind error must not fail the bootstrap.
var req guestAuthRequest var req guestAuthRequest
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ, req.Language) acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
+3 -29
View File
@@ -12,7 +12,6 @@ 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.
@@ -67,25 +66,9 @@ 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:
shop, ok := s.robokassa.Shop(cxt.Subtype) if s.robokassa.MerchantLogin == "" {
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
} }
@@ -106,7 +89,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: shop.PaymentURL(res.OrderID, res.Amount.Major(), res.Title), RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa, Rail: providerRobokassa,
}) })
case payments.SourceVK: case payments.SourceVK:
@@ -153,16 +136,7 @@ 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)
} }
// The per-shop Result route carries the channel as ?channel=; the legacy bare route defaults to orderID, outSum, ok := s.robokassa.VerifyResult(v)
// 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")
-6
View File
@@ -285,12 +285,6 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
// or a completed link (the active account's refreshed profile). // or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse { func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
ctx := c.Request.Context() ctx := c.Request.Context()
if res.Merged {
// A guest initiator's merge ran inline (the guest-primary rule; no confirmation step),
// so render the completed merge — the client adopts the switched session and lands on
// the surviving durable account as if it simply logged in.
return s.mergeResultResponse(c, res.Merge)
}
if res.MergeRequired { if res.MergeRequired {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()} out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil { if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
+4 -4
View File
@@ -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 — one merchant shop per channel; an empty // Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
// set leaves the order and Result-callback endpoints unregistered. // order and Result-callback endpoints unregistered.
Robokassa robokassa.Shops Robokassa robokassa.Config
} }
// 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.Shops robokassa robokassa.Config
notifier notify.Publisher notifier notify.Publisher
console *adminconsole.Renderer console *adminconsole.Renderer
exportKey []byte exportKey []byte
-12
View File
@@ -142,18 +142,6 @@ 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
+7 -12
View File
@@ -79,7 +79,6 @@ 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
@@ -98,7 +97,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. | | `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image (build-arg). **Deploy-authoritative**: a redeploy that bumped it **delivers** the version onto the volume (add-only) and **activates** it for new games — old games keep the version they pin, and a newer out-of-band console upload is never downgraded (ARCHITECTURE.md §5). The `.seed_version` marker still guards the flat seed's label (a bump is delivered as a new subdirectory, never a relabel). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. | | `DICT_VERSION` | variable (shared) | `v1.3.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). One shared unprefixed Gitea variable seeds both contours and pins the CI test suite. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. | | `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
@@ -176,14 +175,10 @@ For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`);
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no `docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
default — a missing value fails loudly instead of baking a stale tag. default — a missing value fails loudly instead of baking a stale tag.
Bumping `DICT_VERSION` and **redeploying** takes a live contour/prod to the new dictionary: Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
on boot the backend **delivers** the build's version onto the volume (add-only, into seed-drift guard). A running contour/prod moves to a new release **through the admin console**
`BACKEND_DICT_DIR/<version>/` from the unshadowed `BACKEND_DICT_SEED_DIR`) and **activates** it, `/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
so new games pin it while in-flight games keep the version they started on. The `.seed_version` keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
marker still guards the flat seed's label (the delivery is a new subdirectory, never a relabel).
The admin console `/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm)
remains for an **out-of-band** update without a redeploy; a console version newer than the build
survives a later restart on an older image (ARCHITECTURE.md §5).
## Production rollout ## Production rollout
@@ -261,8 +256,8 @@ web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release
the SDK is missing or unreadable. the SDK is missing or unreadable.
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated): - **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`, `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`), then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_RUSTORE_KEYSTORE_PASSWORD`, `ANDROID_RUSTORE_KEY_ALIAS`, `ANDROID_RUSTORE_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL` `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
variable to the store listing once published (empty until then — the in-app update button no-ops). 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 - **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 `GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
-12
View File
@@ -171,18 +171,6 @@ 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
+16 -21
View File
@@ -446,27 +446,22 @@ Key points:
version while new games use the new one. A restart re-loads every resident version while new games use the new one. A restart re-loads every resident
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory, version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
skipping the `.staging/` upload area) and restores the active pointer from skipping the `.staging/` upload area) and restores the active pointer from
`dictionary_state`. The volume preserves uploaded versions across redeploys, so `dictionary_state`. The volume preserves uploaded versions across redeploys;
in-flight games keep replaying on the version they pin. The build's once seeded it is not re-seeded, so after bootstrap dictionary changes go through
`BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion` the console rather than a rebuild. Because the flat DAWGs carry no embedded
copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the version, `OpenWithVersions` records the version the flat directory was first seeded
volume mount hides the image's `BACKEND_DICT_DIR` copy — into at in a `.seed_version` marker on the volume and treats that marker as
`BACKEND_DICT_DIR/<version>/` when that version is not already resident (**add-only**: **authoritative** (the **seed-drift guard**): on an already-seeded volume a later
the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it `BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the
the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the already-seeded bytes — which would otherwise silently serve the wrong dictionary
new dictionary automatically, while old games stay on theirs — no console step. The and void games pinned to the prior label. A running contour therefore moves to a
one exception: a version installed out-of-band through the console that is **newer** new release **through the console** (the prior version stays resident, so its games
than the build version and still resident is kept, so a restart on an older image never keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only:
downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no bumping it on a live contour is a harmless no-op that takes effect on the next
embedded version, `OpenWithVersions` records the version the flat directory was first fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`.
seeded at in a `.seed_version` marker and treats it as authoritative for **that flat (The dictionaries ship as a versioned **release artifact** from the
directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels `scrabble-dictionary` repo; the build's `DICT_VERSION` selects
the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so only the seed.)
the guard still prevents mis-serving a game pinned to the flat label. The console
remains the way to update a dictionary **without** a redeploy (an out-of-band upload).
Set `DICT_VERSION` per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`. (The
dictionaries ship as a versioned **release artifact** from the `scrabble-dictionary`
repo.)
- Move generation/validation/scoring use `Solver.GenerateMoves` (ranked), - Move generation/validation/scoring use `Solver.GenerateMoves` (ranked),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses `Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag** `scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
+7 -10
View File
@@ -50,7 +50,7 @@ tail — at a real control, and a **tap anywhere** advances to the next; after t
overlay is gone for good. Two independent series run. The **lobby** series points at the overlay is gone for good. Two independent series run. The **lobby** series points at the
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game ⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange, board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**, 🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
@@ -117,15 +117,12 @@ First platform contact auto-provisions a durable account. From the profile a pla
links an email (via a confirm code) or their Telegram (via the web sign-in); a guest links an email (via a confirm code) or their Telegram (via the web sign-in); a guest
who links their first identity becomes a durable account. The "already taken" status who links their first identity becomes a durable account. The "already taken" status
of an identity is never revealed before the code/sign-in is verified. If the linked of an identity is never revealed before the code/sign-in is verified. If the linked
identity already belongs to another account, the two accounts are merged into one identity already belongs to another account, the player is shown an explicit,
(statistics summed, games and friends transferred, wallet folded, duplicates removed). **irreversible** confirmation and the two accounts are merged into the one they are
A **durable** initiator is shown an explicit, **irreversible** confirmation first — using (statistics summed, games and friends transferred, duplicates removed) — except
consolidating two real accounts is consequential. A **guest** initiator is not: the when a guest links an identity that already has a durable account, where the durable
durable account that owns the identity is kept, and the ephemeral guest — with its games, account is kept and the guest's games move into it. A merge is blocked only while the
wallet and stats folded in — is retired **seamlessly**, so from the player's side it is two accounts share a game still in progress.
simply signing into their account (the app switches to it with no prompt). A merge is
blocked only while the two accounts share a game still in progress; a guest is then asked
to finish that shared game before signing in.
The profile lists the account's **sign-in methods**. On the web a player can add The profile lists the account's **sign-in methods**. On the web a player can add
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
+7 -9
View File
@@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби** следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен, игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только 🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала; **после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате. по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
@@ -121,14 +121,12 @@ Telegram держит **единого бота**: все игроки поль
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость, привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется, принадлежит другому аккаунту, игроку показывают явное **необратимое**
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному** подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух (статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение запрещено, только пока у аккаунтов есть общая незавершённая игра.
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
+1 -4
View File
@@ -54,10 +54,7 @@ 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** 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): 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 - `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite)
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_foreground.png` + `ic_launcher_background.png` — adaptive layers
- `mipmap-*/ic_launcher_monochrome.png` — themed layer - `mipmap-*/ic_launcher_monochrome.png` — themed layer
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with - `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with
+5 -6
View File
@@ -123,13 +123,12 @@ So Android is **not** the full-bleed master — it is built as two layers:
and square** (no rounded corners; the OS supplies the shape). It fills the whole 108 dp 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. layer, so the sacrificial outer ring is just more wood.
- **Foreground****only the «Э» + ✻**, transparent, **re-placed** to sit safely inside - **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 %) the mask:
scaled **0.75 about the icon centre****25 % smaller**, its «Э» ↔ ✻ arrangement - «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 % - ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
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. tucked closer to the letter.
- The mark is centred, then nudged **right 8 % / down 3 %** so it reads optically
centred under the round mask, and the whole group fits within the **61 % safe zone**.
Everything else (palette, grain, gradients, star construction) is identical to the master. Everything else (palette, grain, gradients, star construction) is identical to the master.
-15
View File
@@ -57,21 +57,6 @@ 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:
+6 -55
View File
@@ -234,53 +234,11 @@ 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. Налоги/чеки — авто через провайдера (ревизия: владелец переходит на ИП).** - **D41. Налоги/чеки — авто через провайдера.** **Robokassa** (direct) — авточек НПД
**Robokassa** (direct) — фискальный чек по **54-ФЗ** через облачную кассу Robokassa (режим самозанятого). **VK** — сам процессит Голоса через налоговую (владельцу делать
(провайдер как фискальный агент): чек формирует касса, не банк (банковский слип об оплате — нечего). **TG Stars** — налоговой стороны нет (для РФ-самозанятого невыводимы легально
отдельный документ, не фискальный чек). Настраивается владельцем в ЛКК Robokassa = не доход по НПД; принимаем, чек не формируем). ОРД по VK-рекламе — на стороне VK.
(касса + ОФД + СНО). Код — опционально: слать детализированный `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.
## Заметки к оформлению документов ## Заметки к оформлению документов
@@ -296,17 +254,10 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
feedback-память `prefer-interview-mode` после plan mode. feedback-память `prefer-interview-mode` после plan mode.
## Все развилки закрыты (D1-D46) ## Все развилки закрыты (D1-D41)
Интервью завершено. Дальше — оформление документов и реализация по релизам. Интервью завершено. Дальше — оформление документов и реализация по релизам.
**Дополнение 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 — «слоями»)
Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка Владелец выбрал слоёную стратегию: сначала вся механика без реальных денег (обкатка
-15
View File
@@ -57,21 +57,6 @@
в контексте 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. Три операции, которые нельзя путать
Не смешивать — у них разные ключи: Не смешивать — у них разные ключи:
+2 -2
View File
@@ -53,7 +53,7 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings → **App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → hints → 🔀 shuffle → ✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself; last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
@@ -284,7 +284,7 @@ e2e and the screenshots.
opens a dialog — pick tiles to **Exchange N**, or pick opens a dialog — pick tiles to **Exchange N**, or pick
none to **Pass without exchanging**; pass is always available on your turn, exchange only none to **Pass without exchanging**; pass is always available on your turn, exchange only
while the bag still holds a full rack, below which the tiles disable and only the pass while the bag still holds a full rack, below which the tiles disable and only the pass
remains), Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label, remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
no confirm), which no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the **animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
+6 -12
View File
@@ -312,12 +312,11 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
} }
// GuestAuth provisions a guest account and mints a session, seeding its time zone // GuestAuth provisions a guest account and mints a session, seeding its time zone
// from browserTz (the client's detected "±HH:MM" UTC offset) and its interface // from browserTz (the client's detected "±HH:MM" UTC offset).
// language from language (the client's detected locale; best-effort). func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype, language string) (SessionResp, error) {
var out SessionResp var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
map[string]string{"browser_tz": browserTz, "subtype": subtype, "language": language}, &out) map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
return out, err return out, err
} }
@@ -499,15 +498,10 @@ type robokassaResultResp struct {
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the // RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
// single writer, which verifies the signature and credits) and returns the body to echo to // single writer, which verifies the signature and credits) and returns the body to echo to
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields; channel selects the // Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
// per-shop signature verifier (empty → the web shop). func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
func (c *Client) RobokassaResult(ctx context.Context, channel string, params map[string]string) (string, error) {
var out robokassaResultResp var out robokassaResultResp
path := "/api/v1/internal/payments/robokassa/result" err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
if channel != "" {
path += "?channel=" + url.QueryEscape(channel)
}
err := c.do(ctx, http.MethodPost, path, "", "", params, &out)
return out.Response, err return out.Response, err
} }
-47
View File
@@ -1,47 +0,0 @@
package connectsrv
import "net/http"
// nativeWebViewOrigins are the browser Origins of the packaged native apps. Capacitor serves the
// bundled SPA from a localhost scheme, so its Connect calls to the gateway are cross-origin; the web
// app is same-origin and needs no entry. Requests carry Authorization, so the allowlist reflects the
// exact origin rather than "*".
var nativeWebViewOrigins = map[string]bool{
"https://localhost": true, // Capacitor Android (default https scheme)
"http://localhost": true, // Capacitor with the http scheme
"capacitor://localhost": true, // Capacitor iOS default scheme
}
// withNativeCORS answers the CORS preflight and adds the CORS response headers for the packaged
// native apps' cross-origin calls to the Connect edge. Without it the WebView blocks every RPC on the
// preflight (the gateway otherwise returns 405 with no Access-Control-Allow-Origin), so a native
// build can never reach the gateway and stays stuck offline. Same-origin (web) and any non-native
// origin are untouched.
func withNativeCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if nativeWebViewOrigins[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Add("Vary", "Origin")
h.Set("Access-Control-Allow-Credentials", "true")
// The client interceptor reads the soft-tier update signal off the response.
h.Set("Access-Control-Expose-Headers", "X-Update-Recommended")
if r.Method == http.MethodOptions {
h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
// Reflect the requested headers (the origin is already allowlisted): the Connect client
// sends Connect-Protocol-Version / Connect-Timeout-Ms plus X-Client-Version and
// Authorization, and the exact set varies by call.
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
h.Set("Access-Control-Allow-Headers", reqHeaders)
} else {
h.Set("Access-Control-Allow-Headers", "Content-Type, Connect-Protocol-Version, X-Client-Version, Authorization")
}
h.Set("Access-Control-Max-Age", "86400")
w.WriteHeader(http.StatusNoContent)
return
}
}
next.ServeHTTP(w, r)
})
}
-54
View File
@@ -1,54 +0,0 @@
package connectsrv
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestWithNativeCORS(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
h := withNativeCORS(next)
// Native-origin preflight → 204 with the CORS headers, reflecting the requested headers, without
// reaching the inner handler.
req := httptest.NewRequest(http.MethodOptions, "/scrabble.edge.v1.Gateway/Execute", nil)
req.Header.Set("Origin", "https://localhost")
req.Header.Set("Access-Control-Request-Method", "POST")
req.Header.Set("Access-Control-Request-Headers", "content-type,connect-protocol-version,x-client-version")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("preflight status = %d, want 204", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://localhost" {
t.Errorf("Allow-Origin = %q, want https://localhost", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); got != "content-type,connect-protocol-version,x-client-version" {
t.Errorf("Allow-Headers = %q, want the reflected set", got)
}
// Native-origin POST → the CORS headers are set and the inner handler runs.
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
req.Header.Set("Origin", "capacitor://localhost")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("POST status = %d, want 200 (passed through)", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "capacitor://localhost" {
t.Errorf("POST Allow-Origin = %q, want capacitor://localhost", got)
}
// A non-native origin gets no CORS headers and still passes through (web is same-origin anyway).
req = httptest.NewRequest(http.MethodPost, "/scrabble.edge.v1.Gateway/Execute", nil)
req.Header.Set("Origin", "https://evil.example")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("non-native POST status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("non-native Allow-Origin = %q, want empty", got)
}
}
@@ -1,17 +0,0 @@
package connectsrv
import "testing"
func TestRobokassaChannel(t *testing.T) {
cases := map[string]string{
"/pay/robokassa/result": "", // legacy bare path → the backend defaults to the web shop
"/pay/robokassa/result/": "", // trailing slash, no channel → the web default
"/pay/robokassa/result/web": "web",
"/pay/robokassa/result/android": "android",
}
for path, want := range cases {
if got := robokassaChannel(path); got != want {
t.Errorf("robokassaChannel(%q) = %q, want %q", path, got, want)
}
}
}
+3 -22
View File
@@ -274,11 +274,8 @@ func (s *Server) HTTPHandler() http.Handler {
mux.Handle("/dict/", s.dictBytesHandler()) mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler()) mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single // Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects. The // crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
// per-shop callback rides a channel suffix (/pay/robokassa/result/<channel>); the bare path is
// the legacy web shop. Caddy's /pay/* matcher already forwards both, so no Caddyfile change.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler()) mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/robokassa/result/", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler()) mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята.")) mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена.")) mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
@@ -302,7 +299,7 @@ func (s *Server) HTTPHandler() http.Handler {
// honeypot hit is turned away before the body cap and the mux. Every request // honeypot hit is turned away before the body cap and the mux. Every request
// body on the public listener is then capped (the admin proxy POSTs included); // body on the public listener is then capped (the admin proxy POSTs included);
// the h2c server carries explicit stream/idle sizing. // the h2c server carries explicit stream/idle sizing.
return h2c.NewHandler(s.abuseGuard(withNativeCORS(maxBodyHandler(s.maxBodyBytes, mux))), &http2.Server{ return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{
MaxConcurrentStreams: h2cMaxConcurrentStreams, MaxConcurrentStreams: h2cMaxConcurrentStreams,
IdleTimeout: h2cIdleTimeout, IdleTimeout: h2cIdleTimeout,
}) })
@@ -472,7 +469,6 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
return connect.NewResponse(&edgev1.ExecuteResponse{ return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(), RequestId: req.Msg.GetRequestId(),
ResultCode: code, ResultCode: code,
Message: transcode.DomainMessage(err),
}), nil }), nil
} }
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err)) s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
@@ -641,21 +637,6 @@ func (s *Server) exportDownloadHandler() http.Handler {
}) })
} }
// robokassaResultPrefix is the channel-suffixed Result path; the segment after it names the
// per-shop channel (matches the direct-rail X-Platform subtype: "web", "android").
const robokassaResultPrefix = "/pay/robokassa/result/"
// robokassaChannel extracts the shop channel from a Result callback path: the segment after
// robokassaResultPrefix, or "" for the legacy bare /pay/robokassa/result (the backend then defaults
// to the web shop). The backend selects the per-shop signature verifier from it.
func robokassaChannel(path string) string {
rest := strings.TrimPrefix(path, robokassaResultPrefix)
if rest == path {
return ""
}
return rest
}
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single // robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's // writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted, // "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
@@ -680,7 +661,7 @@ func (s *Server) robokassaResultHandler() http.Handler {
for k := range r.Form { for k := range r.Form {
params[k] = r.Form.Get(k) params[k] = r.Form.Get(k)
} }
resp, err := s.backend.RobokassaResult(r.Context(), robokassaChannel(r.URL.Path), params) resp, err := s.backend.RobokassaResult(r.Context(), params)
if err != nil { if err != nil {
s.log.Warn("robokassa result proxy failed", zap.Error(err)) s.log.Warn("robokassa result proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway) http.Error(w, "not accepted", http.StatusBadGateway)
+2 -14
View File
@@ -189,17 +189,6 @@ func (r *Registry) Lookup(messageType string) (Op, bool) {
return op, ok return op, ok
} }
// DomainMessage returns the human-readable, already-localized reason a backend domain error carries
// for the user (e.g. an operator's payment-unavailable explanation), or "" when it carries none. It
// rides the Execute envelope's message field alongside the result code.
func DomainMessage(err error) string {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) {
return apiErr.Message
}
return ""
}
// DomainCode maps an error to a stable result code to surface in the Execute // DomainCode maps an error to a stable result code to surface in the Execute
// envelope, reporting false for an unexpected error the caller should treat as a // envelope, reporting false for an unexpected error the caller should treat as a
// transport-level internal failure. // transport-level internal failure.
@@ -270,14 +259,13 @@ func authGuestHandler(backend *backendclient.Client) Handler {
// The guest bootstrap historically carried no payload; the detected zone is // The guest bootstrap historically carried no payload; the detected zone is
// optional, so an absent or empty one simply yields no time-zone seed (rather // optional, so an absent or empty one simply yields no time-zone seed (rather
// than panicking in GetRootAs* on a zero-length buffer). // than panicking in GetRootAs* on a zero-length buffer).
var browserTz, subtype, locale string var browserTz, subtype string
if len(req.Payload) > 0 { if len(req.Payload) > 0 {
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0) g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
browserTz = string(g.BrowserTz()) browserTz = string(g.BrowserTz())
subtype = string(g.Subtype()) subtype = string(g.Subtype())
locale = string(g.Locale())
} }
sess, err := backend.GuestAuth(ctx, browserTz, subtype, locale) sess, err := backend.GuestAuth(ctx, browserTz, subtype)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+2 -14
View File
@@ -98,10 +98,6 @@ type ExecuteResponse struct {
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"` ResultCode string `protobuf:"bytes,2,opt,name=result_code,json=resultCode,proto3" json:"result_code,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
// message is an optional, already-localized human-readable reason a domain outcome may carry for
// the user (e.g. a payment-unavailable explanation set by an operator). Additive and
// frozen-contract-safe; empty for the common case where result_code alone suffices.
Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -157,13 +153,6 @@ func (x *ExecuteResponse) GetPayload() []byte {
return nil return nil
} }
func (x *ExecuteResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
// SubscribeRequest opens the live stream. It is empty: the session is taken from // SubscribeRequest opens the live stream. It is empty: the session is taken from
// the Authorization header. // the Authorization header.
type SubscribeRequest struct { type SubscribeRequest struct {
@@ -273,14 +262,13 @@ const file_edge_v1_edge_proto_rawDesc = "" +
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" + "\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" + "\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x03 \x01(\tR\trequestId\"\x85\x01\n" + "request_id\x18\x03 \x01(\tR\trequestId\"k\n" +
"\x0fExecuteResponse\x12\x1d\n" + "\x0fExecuteResponse\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
"\vresult_code\x18\x02 \x01(\tR\n" + "\vresult_code\x18\x02 \x01(\tR\n" +
"resultCode\x12\x18\n" + "resultCode\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\x12\x18\n" + "\apayload\x18\x03 \x01(\fR\apayload\"\x12\n" +
"\amessage\x18\x04 \x01(\tR\amessage\"\x12\n" +
"\x10SubscribeRequest\"P\n" + "\x10SubscribeRequest\"P\n" +
"\x05Event\x12\x12\n" + "\x05Event\x12\x12\n" +
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" + "\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
-4
View File
@@ -35,10 +35,6 @@ message ExecuteResponse {
string request_id = 1; string request_id = 1;
string result_code = 2; string result_code = 2;
bytes payload = 3; bytes payload = 3;
// message is an optional, already-localized human-readable reason a domain outcome may carry for
// the user (e.g. a payment-unavailable explanation set by an operator). Additive and
// frozen-contract-safe; empty for the common case where result_code alone suffices.
string message = 4;
} }
// SubscribeRequest opens the live stream. It is empty: the session is taken from // SubscribeRequest opens the live stream. It is empty: the session is taken from
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

-11
View File
@@ -24,17 +24,6 @@ allprojects {
} }
} }
// Pin build-tools to the compileSdk-matching version for every Android module (the app + the
// Capacitor modules regenerated by `cap sync`), so AGP does not fall back to its default (35.0.0),
// which the provisioned / read-only CI SDK does not have installed.
subprojects {
afterEvaluate {
if (project.hasProperty('android')) {
android.buildToolsVersion = "36.0.0"
}
}
}
task clean(type: Delete) { task clean(type: Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }
+1 -1
View File
@@ -1,5 +1,5 @@
ext { ext {
minSdkVersion = 29 minSdkVersion = 24
compileSdkVersion = 36 compileSdkVersion = 36
targetSdkVersion = 36 targetSdkVersion = 36
androidxActivityVersion = '1.11.0' androidxActivityVersion = '1.11.0'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 30 KiB

+12 -16
View File
@@ -43,22 +43,20 @@ test.describe('native offline-first', () => {
await page.goto('/'); await page.goto('/');
await expectOfflineGuestLobby(page); await expectOfflineGuestLobby(page);
// Play a local Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the // Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary
// only variant the profileless offline guest is offered (the new-account default), so the single // comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no
// `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from // network is needed — this is the device-local guest playing with zero connectivity.
// the APK's bundled tier (./dict/ru_erudit@dev.dawg served from dist-e2e), so no network is needed
// — this is the device-local guest playing with zero connectivity.
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
await page.locator('.variant').click(); await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('button.invite').click(); await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human plays НОЖ horizontally across the centre (7,5)-(7,7); the local robot replies with a // The human plays WAY horizontally across the centre (7,5)-(7,7); the local robot replies with a
// real move (which needs the bundled dictionary), so the board gains tiles beyond НОЖ's three. // real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three.
await placeTile(page, 'Н', 7, 5); await placeTile(page, 'W', 7, 5);
await placeTile(page, 'О', 7, 6); await placeTile(page, 'A', 7, 6);
await placeTile(page, 'Ж', 7, 7); await placeTile(page, 'Y', 7, 7);
await expect(page.locator('[data-cell].pending')).toHaveCount(3); await expect(page.locator('[data-cell].pending')).toHaveCount(3);
await page.locator('.make').click(); await page.locator('.make').click();
await expect(async () => { await expect(async () => {
@@ -97,16 +95,14 @@ test.describe('native offline-first', () => {
await expectOfflineGuestLobby(page); await expectOfflineGuestLobby(page);
// New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest. // New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest.
// A minimal create: set the mandatory host (master) PIN. A guest host skips the "do you take a // A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats.
// seat?" prompt (no name to seat) and goes straight to the two open seats, so pick the sole
// offered variant (Erudit — the profileless guest's default) right after the PIN.
await page.locator('button.tab').nth(0).click(); await page.locator('button.tab').nth(0).click();
await page.getByRole('button', { name: /With friends|друзьями/i }).click(); await page.getByRole('button', { name: /With friends|друзьями/i }).click();
await page.locator('.hostpin .plink').click(); await page.locator('.hostpin .plink').click();
await typePin(page, '9999'); await typePin(page, '9999');
await typePin(page, '9999'); await typePin(page, '9999');
await expect(page.getByRole('button', { name: /^(No|Нет)$/ })).toHaveCount(0); // no seat prompt for a guest await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
await page.locator('.variant').click(); await page.locator('.variant', { hasText: 'Scrabble' }).click();
await page.locator('.pname').nth(0).fill('Ann'); await page.locator('.pname').nth(0).fill('Ann');
await page.locator('.pname').nth(1).fill('Bob'); await page.locator('.pname').nth(1).fill('Bob');
await page.locator('button.invite').click(); await page.locator('button.invite').click();

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