Compare commits

..

58 Commits

Author SHA1 Message Date
developer d94d56b461 Merge pull request 'release: promote development → master (v1.23.0)' (#276) from development into master 2026-07-15 00:32:54 +00:00
developer d48797e297 Merge pull request 'native polish 2: two-robots fix, guest offline-host UX, Guest###### name, silent+fast native boot, minSdk 29' (#275) from feature/native-polish-2 into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 22s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-15 00:26:27 +00:00
Ilia Denisov fe29c6fe58 chore(android): raise minSdk to 29 (Android 10)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bundled WebView build is only compatible from Android 10 (API 29); on Android 9 it lands on
the in-app unsupported-engine screen. Declaring minSdk 29 lets RuStore/Google Play block older
devices at install rather than shipping a known-incompatible build.
2026-07-15 02:17:13 +02:00
Ilia Denisov 3429dbec5f fix(boot): native offline-first launch is silent and fast (Android 10)
Two native offline-first boot glitches (seen on Android 10):
- bootOffline forced offlineNoNetwork, so its first successful reconcile healed to online with a
  spurious "back online" toast on a first launch that actually had connectivity. The boot-assumed
  offline is now PROVISIONAL: the first probe confirms it — a success heals silently (nothing to
  come back from), a failure turns it into a real offline whose later recovery does toast.
- A native launch with a cached guest session (no email) skipped the offline short-circuit and hung
  ~25 s on adoptSession's retrying profile fetch in airplane mode. The native channel is offline-first,
  so it now takes the same reachability-gated fallback (~3 s bound) as an installed email PWA.
2026-07-15 02:17:13 +02:00
Ilia Denisov 74026223ee feat(account): give a fresh guest a distinct "Guest######" display name
Every guest showed a bare "Guest" to opponents (e.g. in a random match). Mint the display name
as "Guest" + a random six-digit suffix at provisioning, so guests are distinguishable. Not an
identifier and not unique — a label only, so collisions are harmless.
2026-07-15 02:17:13 +02:00
Ilia Denisov 896b7f57a7 feat(newgame): skip the take-a-seat prompt for a guest offline host
A guest host has no meaningful display name to seat, so the offline 'with friends' flow's
"do you take a seat?" prompt only produced a nameless seat. For a guest the prompt is skipped:
after the master PIN, the two empty player seats show directly. A durable host is still asked.
2026-07-15 02:17:13 +02:00
Ilia Denisov aa803260a1 fix(game): recognise the device-local guest as "me" in the game header
The game header identified the viewer's seat only by app.session.userId, but a device-local
game (vs_ai / hotseat) created offline is seated under the local guest id (the lobby already
matches both). After a merge switched the active account to a durable id, the human seat matched
neither, so a vs_ai game showed BOTH seats as robots (the turn still worked — it is seat-index
based). seatName now matches app.session.userId OR localGuestId, like the lobby. Completes the
v1.22.0 repointLocalGameSeats fix, which only covered games seated under the retired session id.
2026-07-15 02:17:13 +02:00
developer 904e92aac8 Merge pull request 'release: promote development → master (v1.22.0)' (#274) from development into master 2026-07-14 23:13:25 +00:00
developer 8758cbd35d Merge pull request 'native polish: guest-locale banner, seamless guest sign-in (+local-game fix), 'Opponent's turn' label, deploy-authoritative dictionary' (#273) from feature/native-polish-and-dict-deploy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m16s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m49s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 23:07:05 +00:00
Ilia Denisov 1d306ec0fc chore(ui): change hints symbol for backward unicode compatibility
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
2026-07-15 00:56:47 +02:00
Ilia Denisov 513b772cd4 feat(game): show 'Opponent's turn' instead of the opponent's name in the turn strip
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
When it is not the viewer's turn in an online PvP or vs_ai game, the under-board status
now reads the generic 'Opponent's turn' (game.opponentsTurn) rather than the opponent's
display name or the robot mark, so whose turn it is reads uniformly. The hotseat branch
still names the seat to move (whose device-turn it is matters there), and the seat row
below continues to name each player.
2026-07-15 00:34:58 +02:00
Ilia Denisov da25eac070 feat(link): auto-merge a guest initiator into its durable account (seamless sign-in)
When a guest links an identity (email/TG/VK) that already belongs to a durable account,
the guest-primary rule already made the durable account the survivor and switched the
session — but the client still showed an irreversible 'merge two accounts?' confirmation,
which is nonsense from the user's side (they are simply signing into their account). The
confirm step now merges inline for a GUEST initiator and returns the completed merge (the
switched token); a durable initiator still gets the explicit confirmation (consolidating
two real accounts is consequential). The active-game guard still refuses, surfaced as the
clear error.merge_active_game_conflict message rather than swallowed.

Also fixes the merged-away device-local games: their human/host seat was recorded under
the retired account id, so after the switch the lobby and game header could not identify
'me' and showed every seat as an opponent (the game still played — turn logic is
seat-index based). applyLinkResult now re-points local game seats from the retired id to
the survivor (repointLocalGameSeats).

Docs: FUNCTIONAL.md (+_ru).
2026-07-15 00:34:58 +02:00
Ilia Denisov 48614e622d feat(dict): make the build's dictionary version deploy-authoritative
The dictionary lives on a persistent volume seeded from the image once and never
re-seeded, so a redeploy that bumped BACKEND_DICT_VERSION left the new DAWGs
unreachable (the volume mount shadows the image copy) and the active version stuck
at whatever the admin console last installed — a native offline-first client then
fetched the server's older pinned version over the network instead of using its
bundled dict.

On boot the backend now DELIVERS the build version onto the volume add-only, from a
second unshadowed image copy at BACKEND_DICT_SEED_DIR, into DICT_DIR/<version>/ when
absent (the flat seed and prior uploads untouched, so in-flight games keep theirs),
and InitActiveVersion makes the build version active — except a console-installed
version NEWER than the build (compared numerically) is not downgraded by a restart.
The .seed_version guard still protects the flat seed's label (a bump is a new
subdirectory, never a relabel). The console stays for out-of-band updates.

Docs: ARCHITECTURE.md §5, deploy/README.md, seedmarker.go. Tests: engine.DeliverVersion
(add-only, idempotent, flat-seed skip), compareDictVersions, and the dictionary-update
integration test's deploy-delivers path.
2026-07-15 00:21:20 +02:00
Ilia Denisov f8aeec8008 fix(account): seed a fresh guest's interface language from the client locale
A guest was provisioned with only its detected time zone; its PreferredLanguage
fell to the 'en' column default. The client already sends its locale in the guest
login (GuestLoginRequest.locale), but the gateway dropped it and ProvisionGuest
took no language — so a Russian user's brand-new native guest got English
language-dependent server content (the ad banner, bot messages) until the client's
later language reconcile. Read the locale in the gateway guest handler, thread it
through GuestAuth -> ProvisionGuest, and seed PreferredLanguage from it (validated;
an unsupported/absent value keeps the 'en' default). Wire field already present —
no schema change.
2026-07-15 00:06:23 +02:00
developer e836dfefba Merge pull request 'release: promote development → master (v1.21.0)' (#272) from development into master 2026-07-14 20:29:04 +00:00
developer b5fe61279b Merge pull request 'android: shrink adaptive icon mark 25% + full-bleed legacy square + Erudit-only default for the profileless client' (#271) from feature/native-icons-variant-defaults into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-14 20:23:04 +00:00
Ilia Denisov e07886ecb1 test(ui): update the native offline-first e2e for the Erudit-only guest default
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m14s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
The offline native guest is now offered only Erudit (the profileless default
from the variants fix), so the specs that picked the English "Scrabble"
variant timed out. Play Erudit instead: the single `.variant` click both
selects it and asserts nothing else is offered; the vs_ai test plays НОЖ from
the pinned Erudit rack (ОЖЬЯНАО), and the bundled ru_erudit dawg drives the
robot reply.
2026-07-14 22:13:42 +02:00
Ilia Denisov 4a3e12c85a feat(icons): shrink the Android adaptive mark 25%, give old square launchers the full-bleed master
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 2m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
The adaptive foreground's asterisk grazed the round mask's safe zone. Scale the
foreground mark 0.75 about the icon centre (25% smaller, the letter-to-star
arrangement unchanged) so it sits well inside the 61% safe zone. Independently,
the legacy square ic_launcher.png now renders the full-bleed master instead of
the safe-zone composite, so old Android (< API 26) shows a large mark rather
than a shrunk one; the legacy round icon keeps the safe-zone composite (a round
mask would clip the master's corner star). Regenerate the layer set, Android
res and the brandbook foreground previews; update ICON_BRANDBOOK.md / ICONS.md.
2026-07-14 21:57:32 +02:00
Ilia Denisov 54bc31c619 fix(ui): default the profileless client to Erudit only, not every variant
availableVariants fell back to all three variants when the player had no
stored preferences. A fresh offline native launch boots with no profile, so
New Game exposed the English game before the player opted in — contrary to the
backend's Erudit-only new-account default (docs/FUNCTIONAL.md). Fall back to
DEFAULT_VARIANTS (Erudit only) instead; all dictionaries stay bundled.
2026-07-14 21:57:21 +02:00
developer d89438040b Merge pull request 'fix(gateway): allow the native WebView origin via CORS (native stuck-offline)' (#270) from feature/native-cors into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m56s
2026-07-14 19:38:27 +00:00
developer 363f1632b1 Merge pull request 'android: ANDROID_PLAN refresh + pin build-tools 36 (unblocks the release build)' (#269) from feature/android-plan-refresh into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m57s
2026-07-14 19:38:13 +00:00
Ilia Denisov f0b7ad47d4 fix(gateway): allow the native WebView origin via CORS
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
The packaged native app (Capacitor) serves the bundled SPA from a localhost scheme, so its Connect calls to erudit-game.ru are cross-origin. The gateway had no CORS handling, so the preflight OPTIONS returned 405 with no Access-Control-Allow-Origin and the WebView blocked every RPC — a native build could never reach the gateway and stayed stuck offline (the native online path was never exercised on-device; on-device D was airplane-mode only). Add a CORS middleware that answers the preflight and sets the response headers for the native localhost origins (https/http/capacitor://localhost); web is same-origin and untouched. Fixes the native emulator 'starts offline, can't go online' report.
2026-07-14 21:21:53 +02:00
Ilia Denisov 0e11817654 fix(android): pin build-tools to 36.0.0 for all modules
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m51s
AGP 8.13 defaults to build-tools 35.0.0, but the provisioned / read-only CI SDK (/opt/android-sdk) has only 36.0.0 installed, so a gradle build (the app + the cap-sync-generated Capacitor modules) fails trying to auto-install 35 into a read-only dir. Pin buildToolsVersion 36.0.0 for every Android sub-project (compileSdk-matching) so the CI android-build signed release and local builds work. Verified: a local assembleDebug now builds app-debug.apk. ci.yaml does not build Android (only the manual android-build workflow does), so this is verified locally.
2026-07-14 20:43:58 +02:00
Ilia Denisov d0e473920c docs(android): refresh the ANDROID_PLAN RESUME block
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
Keystore + its Gitea secrets are set (owner) under the RuStore-specific ANDROID_RUSTORE_* names; the payments E10/E11 (#266/#267) + the signing-secret rename (#268) landed on development; the release gate is now the RuStore account + the promote/tag chain. Adds a native-notifications (push) track and the test-APK note for the next session.
2026-07-14 20:24:26 +02:00
developer 0e56e4de9a Merge pull request 'chore(android): rename RuStore signing secrets ANDROID_* -> ANDROID_RUSTORE_*' (#268) from feature/rustore-signing-secrets into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-14 18:21:36 +00:00
Ilia Denisov 8d3f862b41 chore(android): rename the RuStore signing secrets ANDROID_* -> ANDROID_RUSTORE_*
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
The signing key is store-specific: RuStore self-signs (the cert is the app cert), while Google Play re-signs via Play App Signing (the uploaded key is only the upload key). Keep a separate key per store and name the secrets accordingly. Only the Gitea secret names change; build.gradle keeps the store-agnostic env contract (ANDROID_KEYSTORE_*), so the same build serves a future Google key from its own workflow. New names: ANDROID_RUSTORE_KEYSTORE_BASE64 / _KEYSTORE_PASSWORD / _KEY_ALIAS / _KEY_PASSWORD.
2026-07-14 20:08:03 +02:00
developer 74842dc624 Merge pull request 'feat(payments): live payment-availability kill switch + per-account override' (#267) from feature/payment-kill-switch into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m15s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m58s
2026-07-14 14:39:35 +00:00
Ilia Denisov 1507ceb793 feat(payments): live payment-availability kill switch + per-account override
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m0s
Let an operator disable purchases live from the admin — a whole rail/channel or
one account — and show the user a localized reason on their next attempt, so a
provider outage or a misconfig is explained instead of a silent dead button.

- 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 — a rail with no row stays 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): allow / deny / default, edited on the user card. "allow" bypasses
  ONLY the ops rail switch, never the security gates (trusted platform, the email
  anchor, the VK-iOS freeze, the min client version).
- wire: an additive ExecuteResponse.message envelope field (frozen-contract-safe);
  the gateway forwards a backend domain-error message; the client shows it on a
  payment_unavailable buy attempt.
- admin: rail toggles on the catalog page, the override control on the user card.
- tests: the pure gate (unit, TDD), the store + gate + override end-to-end
  (integration, migration 00016), the client (svelte-check / vitest).
- docs: PAYMENTS (+ru), the decisions log (D45/D46). Fiscalization stays
  cabinet-side (owner decision) — no itemized-receipt code.

Contour-safe: additive migration (two new tables, no wipe), the wire add is
additive, and fail-open so nothing is disabled until an operator acts.
2026-07-14 16:29:41 +02:00
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
102 changed files with 1480 additions and 183 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
# assembles a release APK, uploaded as a run artifact (RuStore upload stays manual for the MVP).
#
# Signing degrades gracefully: with the ANDROID_KEYSTORE_* secrets present the APK is signed; without
# Signing degrades gracefully: with the ANDROID_RUSTORE_* secrets present the APK is signed; without
# them build.gradle produces an UNSIGNED release APK (so a dry run still proves the whole pipeline).
# The keystore is a publication prerequisite — see deploy/README.md (Android build/release runbook).
# The toolchain is self-provisioned here (JDK 21 + a cached Android SDK), so the runner host needs
@@ -144,7 +144,7 @@ jobs:
- name: Decode the release keystore
id: keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_BASE64 }}
run: |
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "${GITHUB_WORKSPACE}/release.jks"
@@ -152,16 +152,16 @@ jobs:
echo "keystore decoded -> signed release build"
else
echo "file=" >> "$GITHUB_OUTPUT"
echo "::warning::ANDROID_KEYSTORE_BASE64 is not set — building an UNSIGNED release APK (not installable/publishable)"
echo "::warning::ANDROID_RUSTORE_KEYSTORE_BASE64 secret is not set — building an UNSIGNED release APK (not installable/publishable)"
fi
- name: Assemble the release APK
working-directory: ui/android
env:
ANDROID_KEYSTORE_FILE: ${{ steps.keystore.outputs.file }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_RUSTORE_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_RUSTORE_KEY_PASSWORD }}
run: ./gradlew assembleRelease -PversionCode=${{ steps.prep.outputs.code }} -PversionName=${{ steps.prep.outputs.name }} --console=plain
- name: Upload the APK artifact
+28 -17
View File
@@ -62,19 +62,20 @@ client-only (no server change).
Kept current as parts land so a fresh session resumes without re-deriving. Verify against code.
> ### ▶ RESUME HERE — 2026-07-13 (documents track + release-prep)
> ### ▶ RESUME HERE — 2026-07-14 (keystore secrets set; payments landed; gate = RuStore account + merge/tag)
>
> **Where we are.** The legal-documents track, the release-prep hygiene, **and the icon rebrand** are
> DONE. The RuStore release is now gated on **owner-side** items only (keystore, RuStore account) plus
> the merge/tag chain. Do not re-do or re-litigate the finished work; do not re-ask the locked decisions.
> **Where we are.** The legal-documents track, the release-prep hygiene, the icon rebrand **and the
> release keystore** are DONE (the keystore + its Gitea secrets set by the owner — see below). The
> RuStore MVP is now gated on **one owner-side item** — the **RuStore developer account** (owner going
> ИП; 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 (current working branch: `feature/android-release-prep`).**
> - `development` — has the **legal-documents track**, merged via **PR #253** (green, deployed to the
> 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).
> **Branches / PRs.** All merged to `development` (contour-green): legal documents (#253); release-prep
> hygiene + the re-enabled `android-build` workflow (#254); the payments E10/E11 (#266/#267); the
> signing-secret rename (#268). No open Android branch.
>
> **Done this session.**
> - **Legal documents** (satisfies the RuStore *hosted privacy-policy URL* prerequisite): authored
@@ -113,12 +114,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
> **tag `vX.Y.Z`** for the release (the version stamps the APK `versionName`/`versionCode` and the
> `X-Client-Version` header).
> 3. **Release keystore.** OWNER generates it (`keytool -genkeypair -v -keystore erudit-release.jks
> -alias erudit -keyalg RSA -keysize 4096 -validity 10000`), base64-encodes it, sets the four Gitea
> **secrets** `ANDROID_KEYSTORE_BASE64` / `ANDROID_KEYSTORE_PASSWORD` / `ANDROID_KEY_ALIAS` /
> `ANDROID_KEY_PASSWORD`, and backs the `.jks` up **off-host** (loss ⇒ the app can never be updated).
> The AGENT provides the exact commands on request. Without the secrets the workflow builds an
> UNSIGNED APK (a dry run still proves the pipeline).
> 3. **Release keystore — DONE (owner, 2026-07-14).** `erudit-release.jks` (RSA 4096, alias `erudit`,
> PKCS12) generated + backed up off-host; the four Gitea secrets are set under the RuStore-specific
> names `ANDROID_RUSTORE_KEYSTORE_BASE64` / `ANDROID_RUSTORE_KEYSTORE_PASSWORD` /
> `ANDROID_RUSTORE_KEY_ALIAS` / `ANDROID_RUSTORE_KEY_PASSWORD` (loss ⇒ the app can never be updated
> the cert is pinned at the first RuStore upload). Google Play, later, would use its own
> `ANDROID_PLAY_*` upload key (Play App Signing) — a separate key.
> 4. **RuStore developer account.** OWNER (verified legal entity or self-employed / самозанятый). Gates
> publication, not the build.
> 5. **Dispatch the signed build.** AGENT: on `master`, dispatch `android-build` (`confirm=build`) via
@@ -132,6 +133,16 @@ 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
> the store deep-link so the update button works on future gated releases (empty until then — the
> 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
under `ui/android/` (tracked), `@capacitor/*` deps + `capacitor.config.ts`, hardware Back in
+35 -3
View File
@@ -39,7 +39,8 @@ status — without re-deriving decisions.
| E7 | Admin, reports & catalog | 2 | DONE |
| E8 | Guest limits | — | DONE |
| E9 | Tournament fee | future | TODO |
| E10 | Multi-shop direct rail + ИП fiscalization | 2+ | 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 2** = money (E4→E5→E6→E7). E8 is standalone (game-behaviour change, can run in
@@ -896,7 +897,7 @@ tournament-entry storage + pricing design.
## E10 — Multi-shop direct rail + ИП fiscalization
**Status:** TODO · **Release 2+ (post-launch, backend-first)** · depends on: E5 (intake/`Fund`,
**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.
@@ -940,7 +941,9 @@ by the **trusted** `X-Platform` subtype, never a client field; unknown → `web`
- 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) — orthogonal, gated on the owner's ИП/ОФД/СНО.**
**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.
@@ -980,6 +983,35 @@ 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)
- Per-stage tests at the layers above; **compliance-gate regression is mandatory** (a
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+8 -2
View File
@@ -19,6 +19,11 @@ const uri = p => 'data:image/png;base64,' + readFileSync(p).toString('base64');
const BG = uri(join(ROOT, 'ui', 'assets', 'icon-background.png'));
const FG = uri(join(ROOT, 'ui', 'assets', 'icon-foreground.png'));
const MONO = uri(join(DIR, 'android-monochrome.png'));
// The full-bleed master (square tile + master mark) — the legacy SQUARE launcher (API < 26)
// uses it so old Android shows a large mark rather than the safe-zone foreground shrunk into
// the middle. The round legacy icon keeps the safe-zone composite (a round mask would clip
// the master's corner ✻).
const MASTER = uri(join(ROOT, 'ui', 'assets', 'icon.png'));
// density: [adaptive-layer px (108 dp), legacy launcher px (48 dp)]
const D = { ldpi: [81, 36], mdpi: [108, 48], hdpi: [162, 72], xhdpi: [216, 96], xxhdpi: [324, 144], xxxhdpi: [432, 192] };
@@ -38,8 +43,9 @@ 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_foreground.png'), await shot(im(FG, fg), fg, true));
writeFileSync(join(dir, 'ic_launcher_monochrome.png'), await shot(im(MONO, fg), fg, true));
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>`;
writeFileSync(join(dir, 'ic_launcher.png'), await shot(comp, lg, false));
// Square launcher: the full-bleed master (large mark). Round launcher: the safe-zone
// composite, circle-clipped (the master's corner ✻ would be clipped by the round mask).
writeFileSync(join(dir, 'ic_launcher.png'), await shot(im(MASTER, lg), lg, false));
const round = `<div style="width:${lg}px;height:${lg}px;border-radius:50%;overflow:hidden;position:relative">${im(BG, lg)}<div style="position:absolute;inset:0">${im(FG, lg)}</div></div>`;
writeFileSync(join(dir, 'ic_launcher_round.png'), await shot(round, lg, true));
console.log('mipmap-' + d, 'ok');
+6 -2
View File
@@ -33,8 +33,12 @@ const SHADE = 0.15; // black, this alpha bottom-left -> transp
// The mark, placed for the standalone master (bottom-right biased, full-bleed):
const MASTER = { lh: 0.6055, lc: [0.4814, 0.4922], sd: 0.1729, sc: [0.7881, 0.7881] };
// The mark, placed for the Android foreground layer (centred-ish inside the 61% safe
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻):
const FOREGROUND = { lh: 0.5391, lc: [0.5234, 0.4951], sd: 0.1318, sc: [0.7627, 0.7266] };
// zone, nudged right 8% / down 3% for optical balance under the round mask; smaller ✻).
// Every value is the earlier placement scaled 0.75 about the icon centre (0.5, 0.5): the
// whole mark is 25% smaller, its «Э»↔✻ arrangement unchanged, so the ✻ that used to graze
// the round mask now sits well inside the safe zone. Round-mask launchers (adaptive + the
// legacy round icon) get more breathing room; the full-bleed master is untouched.
const FOREGROUND = { lh: 0.4043, lc: [0.5176, 0.4963], sd: 0.0989, sc: [0.6970, 0.6700] };
const PALETTE = {
light: { wood: '#e6b053', ink: '#5a3a12', grain: '#d8a54e' },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+7 -2
View File
@@ -49,9 +49,14 @@ COPY --from=build /out/backend /usr/local/bin/backend
# 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
# can write new version subdirectories at runtime. The volume preserves uploaded
# 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).
# versions across deploys and, once seeded, is not re-seeded (ARCHITECTURE.md §5).
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_SEED_DIR=/opt/dawg-seed
ENV BACKEND_DICT_VERSION=${DICT_VERSION}
ENTRYPOINT ["/usr/local/bin/backend"]
+11 -4
View File
@@ -111,6 +111,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
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)
if err != nil {
return fmt.Errorf("load dictionaries: %w", err)
@@ -118,6 +124,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
defer func() { _ = registry.Close() }()
logger.Info("dictionaries loaded",
zap.String("dir", cfg.Game.DictDir),
zap.String("seed_dir", cfg.Game.DictSeedDir),
zap.String("version", cfg.Game.DictVersion))
// Admin console: an optional backend client to the Telegram connector
@@ -147,10 +154,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
accounts := account.NewStore(db)
accounts.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/account"))
games := game.NewService(game.NewStore(db), accounts, registry, cfg.Game, logger)
// Reconcile the persisted active dictionary version with the registry: a
// version activated through the admin console (and written to the dictionary
// volume) is adopted again after a restart; otherwise the configured seed
// version is kept and persisted (docs/ARCHITECTURE.md §5).
// Reconcile the active dictionary version with the registry: the build version
// (delivered above) becomes active so a redeploy that bumped it goes live for new
// games, except a newer version installed out-of-band through the admin console,
// which is not downgraded by a restart (docs/ARCHITECTURE.md §5).
if err := games.InitActiveVersion(ctx); err != nil {
return fmt.Errorf("init active dictionary version: %w", err)
}
+19 -5
View File
@@ -10,6 +10,7 @@ import (
"database/sql"
"errors"
"fmt"
"math/rand/v2"
"strings"
"time"
@@ -525,8 +526,13 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
return created, nil
}
// guestDisplayName is the display name stamped on a freshly provisioned guest.
const guestDisplayName = "Guest"
// guestDisplayName mints the display name stamped on a freshly provisioned guest: "Guest" plus a
// random six-digit suffix (e.g. "Guest042317"), so a guest is distinguishable to opponents — in a
// 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
// no identity, flagged is_guest, so it can hold a session and a game seat (both
@@ -534,7 +540,7 @@ const guestDisplayName = "Guest"
// 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
// back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ, language string) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err)
@@ -543,9 +549,17 @@ func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account,
if tz == "" {
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.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true, tz).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone, table.Accounts.PreferredLanguage).
VALUES(accountID, guestDisplayName(), true, tz, lang).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -30,6 +30,18 @@
<div><button type="submit">Save</button></div>
</form>
</section>
<section class="panel"><h2>Payment availability (kill switch)</h2>
<p class="note">Turn purchases off on a rail/channel and show the user a localized reason on their next attempt. Unchecked = off; an empty message shows a built-in &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>
<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">
@@ -72,6 +72,16 @@
{{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>
{{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>
{{$uid := .ID}}
{{if .Finance.Ledger}}
+15
View File
@@ -198,6 +198,9 @@ type UserDetailView struct {
// Finance is the account's payments picture (balances, benefits, refund risk, ledger). Present
// is false when the payments domain is unwired.
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
// payments domain is unwired.
Grant GrantFormView
@@ -687,6 +690,18 @@ type CatalogView struct {
RewardPayout int
RewardDailyCap int
RewardHourlyCap int
// Rails is the per-rail operational kill-switch state (enabled + per-language off-message), shown
// in and edited from the page's payment-availability form.
Rails []RailStatusRow
}
// RailStatusRow is one payment rail's operational availability in the kill-switch editor: the rail
// key, whether purchases are enabled, and the operator's per-language off-message shown to the user.
type RailStatusRow struct {
Rail string
Enabled bool
MessageRU string
MessageEN string
}
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
+1
View File
@@ -106,6 +106,7 @@ func Load() (Config, error) {
gm := game.DefaultConfig()
gm.DictDir = envOr("BACKEND_DICT_DIR", gm.DictDir)
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 {
return Config{}, err
}
+92
View File
@@ -0,0 +1,92 @@
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
@@ -0,0 +1,68 @@
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;
// - already-seeded directory: return the recorded marker and ignore bootVersion.
//
// So bumping the build seed on a live volume is a harmless no-op (it only takes
// effect on a future fresh volume) instead of relabelling the already-seeded bytes —
// which would void games pinned to the prior label and mis-serve new ones. New games
// still pin the active version (DB-persisted, set by the admin console), which is the
// real way a running contour moves to a new release.
// So a later build seed never relabels the already-seeded flat bytes — which would void
// games pinned to the prior label and mis-serve new ones. The new build version is instead
// delivered as a NEW subdirectory (DeliverVersion) and made active (Service.InitActiveVersion),
// so a redeploy still moves new games to it while old games keep the flat label. New games
// pin the active version (DB-persisted); a console upload can also set it out-of-band.
//
// 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
+7
View File
@@ -18,6 +18,13 @@ type Config struct {
// DictVersion labels the dictionary version new games pin. Sourced from
// BACKEND_DICT_VERSION.
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.
// Sourced from BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL.
TimeoutSweepInterval time.Duration
+19 -9
View File
@@ -232,21 +232,31 @@ func (svc *Service) ActiveVersion() string {
}
// InitActiveVersion reconciles the persisted active dictionary version with the
// registry at startup. If a version was persisted and is resident, it becomes the
// active version; otherwise the configured seed version is kept and persisted as
// the initial active version. Call once during wiring, before serving traffic.
// registry at startup and makes the build's version (BACKEND_DICT_VERSION, delivered
// resident by engine.DeliverVersion) authoritative: a redeploy that bumped the build
// version thus activates it for new games without any admin step, while old games keep
// 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 {
persisted, ok, err := svc.store.GetActiveDictVersion(ctx)
if err != nil {
return err
}
if ok && svc.versionResident(persisted) {
svc.verMu.Lock()
svc.version = persisted
svc.verMu.Unlock()
return nil
target := svc.activeVersion() // the build seed version (config.DictVersion)
if ok && svc.versionResident(persisted) && compareDictVersions(persisted, target) > 0 {
target = persisted // a newer out-of-band install wins over the build version
}
return svc.store.SetActiveDictVersion(ctx, svc.activeVersion())
if !svc.versionResident(target) && ok && svc.versionResident(persisted) {
target = persisted // the build version is unloadable — keep a resident one
}
svc.verMu.Lock()
svc.version = target
svc.verMu.Unlock()
return svc.store.SetActiveDictVersion(ctx, target)
}
// SetActiveVersion records version as the active dictionary version, persisting it
+45
View File
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,37 @@
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
}
}
+23 -3
View File
@@ -5,6 +5,7 @@ package inttest
import (
"context"
"errors"
"regexp"
"testing"
"time"
@@ -229,21 +230,40 @@ func TestProvisionSeedsTimeZone(t *testing.T) {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
}
// A guest is seeded its detected offset; an empty one keeps the UTC default.
guest, err := store.ProvisionGuest(ctx, "-05:30")
// A guest is seeded its detected offset and interface language; an empty offset keeps the
// UTC default and an empty/unsupported language keeps the 'en' default.
guest, err := store.ProvisionGuest(ctx, "-05:30", "ru")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
}
plainGuest, err := store.ProvisionGuest(ctx, "")
if guest.PreferredLanguage != "ru" {
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 {
t.Fatalf("provision plain guest: %v", err)
}
if plainGuest.TimeZone != "UTC" {
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
+1 -1
View File
@@ -216,7 +216,7 @@ func TestConfirmCodeClearsGuest(t *testing.T) {
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
guest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -103,14 +103,31 @@ func TestDictionaryUpdateFlow(t *testing.T) {
t.Errorf("installed dawg missing on disk: %v", err)
}
// The choice survives a restart: a fresh service over the same DB and registry
// re-adopts the persisted active version.
// The choice survives a restart on the SAME build: an out-of-band admin install newer
// than the build version is not downgraded (a restart on an older image keeps the hotfix).
restarted := newGameServiceOn(dir, "v1.0.0", reg)
if err := restarted.InitActiveVersion(ctx); err != nil {
t.Fatalf("restart init: %v", err)
}
if got := restarted.ActiveVersion(); got != "v9.9.9" {
t.Errorf("restarted active version = %q, want v9.9.9 (persisted)", got)
t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", 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.
+1 -1
View File
@@ -431,7 +431,7 @@ func TestConfirmByTokenLinkClearsGuest(t *testing.T) {
store := account.NewStore(testDB)
mailer := &capturingMailer{}
svc := account.NewEmailService(store, mailer, "https://erudit-game.ru")
guest, err := store.ProvisionGuest(ctx, "")
guest, err := store.ProvisionGuest(ctx, "", "")
if err != nil {
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.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+39 -9
View File
@@ -347,8 +347,10 @@ func TestAccountLinkEmailMergeIntoCaller(t *testing.T) {
}
}
// TestAccountLinkGuestInversion merges a guest initiator into the durable account
// that owns the email: the durable account wins and a fresh session is minted.
// TestAccountLinkGuestInversion auto-merges a guest initiator into the durable account that
// owns the email AT THE CONFIRM STEP (no merge confirmation): the guest is retired, the durable
// 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) {
ctx := context.Background()
store := account.NewStore(testDB)
@@ -364,17 +366,18 @@ func TestAccountLinkGuestInversion(t *testing.T) {
t.Fatalf("request: %v", err)
}
code := sixDigit.FindString(mailer.lastBody)
if _, err := links.ConfirmEmail(ctx, guest, email, code); err != nil {
confirm, err := links.ConfirmEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("confirm: %v", err)
}
merge, err := links.MergeEmail(ctx, guest, email, code)
if err != nil {
t.Fatalf("merge: %v", err)
// A guest initiator does not get a MergeRequired confirmation — the merge runs inline.
if !confirm.Merged || confirm.MergeRequired {
t.Fatalf("confirm = %+v, want an inline (auto) merge", confirm)
}
if merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", merge.PrimaryID, durable)
if confirm.Merge.PrimaryID != durable {
t.Fatalf("primary = %s, want durable %s", confirm.Merge.PrimaryID, durable)
}
if merge.SwitchedToken == "" {
if confirm.Merge.SwitchedToken == "" {
t.Error("a guest initiator whose durable counterpart wins must get a switched session token")
}
if mergedInto(t, guest) != durable {
@@ -385,6 +388,33 @@ 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
// promotes a guest to durable — the ConfirmVK counterpart of the free-email case.
func TestAccountLinkFreeVK(t *testing.T) {
@@ -0,0 +1,83 @@
//go:build integration
package inttest
import (
"context"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// TestPaymentAvailabilityKillSwitch exercises the per-rail kill switch + the per-account override
// end-to-end against Postgres: fail-open, a disabled rail with a localized message, per-rail
// granularity, and the allow/deny/default overrides.
func TestPaymentAvailabilityKillSwitch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
// Fail-open: an untouched rail is enabled.
if ok, _, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); err != nil || !ok {
t.Fatalf("fail-open: CanPurchase = %v/%v, want true", ok, err)
}
// Disable the web rail with a per-language message → blocked with that message, localized.
if err := svc.SetRailStatus(ctx, payments.RailDirectWeb, payments.RailAvailability{MessageRU: "Чиним", MessageEN: "Fixing"}); err != nil {
t.Fatalf("set rail status: %v", err)
}
if ok, reason, err := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "ru"); err != nil || ok || reason != "Чиним" {
t.Fatalf("disabled rail (ru): CanPurchase = %v/%q/%v, want false/Чиним", ok, reason, err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); ok || reason != "Fixing" {
t.Fatalf("disabled rail (en): = %v/%q, want false/Fixing", ok, reason)
}
// Per-rail granularity: another rail stays enabled.
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("android rail should stay enabled")
}
// A per-account "allow" override bypasses the disabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideAllow); err != nil {
t.Fatalf("set override allow: %v", err)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectWeb, "en"); !ok {
t.Fatalf("allow override should bypass the disabled rail")
}
if ov, err := svc.PurchaseOverrideFor(ctx, acc); err != nil || ov != payments.OverrideAllow {
t.Fatalf("PurchaseOverrideFor = %v/%v, want allow", ov, err)
}
// A "deny" override blocks even an enabled rail.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDeny); err != nil {
t.Fatalf("set override deny: %v", err)
}
if ok, reason, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); ok || reason == "" {
t.Fatalf("deny override should block the enabled android rail with a reason, got %v/%q", ok, reason)
}
// Clearing the override (default) restores rail-driven behaviour.
if err := svc.SetPurchaseOverride(ctx, acc, payments.OverrideDefault); err != nil {
t.Fatalf("clear override: %v", err)
}
if ov, _ := svc.PurchaseOverrideFor(ctx, acc); ov != payments.OverrideDefault {
t.Fatalf("after clear, override = %v, want default", ov)
}
if ok, _, _ := svc.CanPurchase(ctx, acc, payments.RailDirectAndroid, "en"); !ok {
t.Fatalf("after clearing override, android rail should be enabled again")
}
// RailStatuses reflects the stored web-rail change and fills the rest fail-open.
all, err := svc.RailStatuses(ctx)
if err != nil {
t.Fatalf("rail statuses: %v", err)
}
if all[payments.RailDirectWeb].Enabled {
t.Fatalf("web rail should read disabled")
}
if !all[payments.RailVK].Enabled {
t.Fatalf("untouched vk rail should read enabled")
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx, "")
guest, err := st.ProvisionGuest(ctx, "", "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
+32 -6
View File
@@ -31,13 +31,39 @@ func NewService(emails *account.EmailService, accounts *account.Store, merger *a
return &Service{emails: emails, accounts: accounts, merger: merger, sessions: sessions}
}
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked or
// MergeRequired is set; SecondaryID is the account to be retired when a merge is
// required (the caller renders an irreversible-merge confirmation from it).
// ConfirmResult reports the outcome of a confirm step. Exactly one of Linked,
// MergeRequired or Merged is set. SecondaryID is the account to be retired when a merge
// is required (the caller renders an irreversible-merge confirmation from it). Merged is
// 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 {
Linked bool
MergeRequired bool
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.
@@ -67,7 +93,7 @@ func (s *Service) ConfirmEmail(ctx context.Context, accountID uuid.UUID, email,
}
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, accountID, owner)
}
// MergeEmail re-verifies the code and merges the address's account into the
@@ -103,7 +129,7 @@ func (s *Service) ConfirmTelegram(ctx context.Context, callerID uuid.UUID, exter
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeTelegram merges the account owning a gateway-validated Telegram identity
@@ -150,7 +176,7 @@ func (s *Service) ConfirmVK(ctx context.Context, callerID uuid.UUID, externalID
if owner == callerID {
return ConfirmResult{Linked: true}, nil
}
return ConfirmResult{MergeRequired: true, SecondaryID: owner}, nil
return s.confirmOrAutoMerge(ctx, callerID, owner)
}
// MergeVK merges the account owning a gateway-validated VK identity into the caller's
+107
View File
@@ -0,0 +1,107 @@
package payments
import "slices"
// PurchaseOverride is a per-account purchase override that forces purchases allowed or denied for
// one account regardless of the operational rail switch, or is absent (OverrideDefault) when the
// account follows the rail switch. The zero value is OverrideDefault, so a missing override row maps
// to it. OverrideAllow bypasses ONLY the operational rail switch — never the security / compliance
// gates (trusted platform, the D36 email anchor, the VK-iOS spend freeze, the min client version),
// which CreateOrder enforces separately.
type PurchaseOverride int
const (
// OverrideDefault follows the rail switch (no override row for the account).
OverrideDefault PurchaseOverride = iota
// OverrideAllow always allows the purchase, bypassing only the operational rail switch.
OverrideAllow
// OverrideDeny always denies the purchase for the account.
OverrideDeny
)
// RailAvailability is a payment rail's operational availability: whether purchases are enabled and,
// when disabled, the operator's explanation in each language, shown to the user on a purchase
// attempt. A rail with no status row is treated as enabled (fail-open — see the store), so the
// zero value is never used as a live "enabled" default.
type RailAvailability struct {
Enabled bool
MessageRU string
MessageEN string
}
// PurchaseGate decides whether an account may open a purchase order on a rail, from the per-account
// override and the rail's operational availability. It is the operational layer only — the caller
// still enforces the security gates. On a block it returns a reason localized to lang ("ru", else
// English). Fail-open: OverrideDefault on an enabled rail allows.
func PurchaseGate(override PurchaseOverride, rail RailAvailability, lang string) (ok bool, reason string) {
switch override {
case OverrideAllow:
return true, "" // bypasses only the ops switch; the security gates still apply upstream
case OverrideDeny:
return false, defaultUnavailable(lang) // a per-account block — a neutral reason
default: // OverrideDefault — follow the rail switch
if rail.Enabled {
return true, ""
}
return false, railMessage(rail, lang)
}
}
// railMessage returns the operator's rail-off message in lang, falling back to the other language
// and then to the built-in default when the operator left both blank.
func railMessage(rail RailAvailability, lang string) string {
if lang == "ru" {
if rail.MessageRU != "" {
return rail.MessageRU
}
if rail.MessageEN != "" {
return rail.MessageEN
}
} else {
if rail.MessageEN != "" {
return rail.MessageEN
}
if rail.MessageRU != "" {
return rail.MessageRU
}
}
return defaultUnavailable(lang)
}
// defaultUnavailable is the built-in localized "payments unavailable" reason used when the operator
// set no custom message, and for a per-account deny.
func defaultUnavailable(lang string) string {
if lang == "ru" {
return "Оплата временно недоступна. Попробуйте позже."
}
return "Payments are temporarily unavailable. Please try again later."
}
// Rail keys name the operational payment rails the kill switch and the per-account override key on.
// The direct rail is split per channel (D42); vk and telegram are single-rail.
const (
RailDirectWeb = "direct:web"
RailDirectAndroid = "direct:android"
RailVK = "vk"
RailTelegram = "telegram"
)
// KnownRails is the fixed set of rail keys, for the admin editor and for validation.
var KnownRails = []string{RailDirectWeb, RailDirectAndroid, RailVK, RailTelegram}
// RailKey maps a payment context to its operational rail key: the direct rail by channel subtype
// (android, else web), or the store rail's own name (vk / telegram).
func RailKey(kind Source, subtype string) string {
if kind == SourceDirect {
if subtype == "android" {
return RailDirectAndroid
}
return RailDirectWeb
}
return string(kind)
}
// isKnownRail reports whether rail is one of the fixed rail keys.
func isKnownRail(rail string) bool {
return slices.Contains(KnownRails, rail)
}
@@ -0,0 +1,40 @@
package payments
import "testing"
func TestPurchaseGate(t *testing.T) {
on := RailAvailability{Enabled: true}
offMsg := RailAvailability{Enabled: false, MessageRU: "Чиним", MessageEN: "Fixing"}
offNoMsg := RailAvailability{Enabled: false}
// OverrideDefault follows the rail switch.
if ok, _ := PurchaseGate(OverrideDefault, on, "en"); !ok {
t.Error("default + enabled rail should allow")
}
if ok, r := PurchaseGate(OverrideDefault, offMsg, "ru"); ok || r != "Чиним" {
t.Errorf("default + off rail (ru) = %v/%q, want false/Чиним", ok, r)
}
if ok, r := PurchaseGate(OverrideDefault, offMsg, "en"); ok || r != "Fixing" {
t.Errorf("default + off rail (en) = %v/%q, want false/Fixing", ok, r)
}
// Off rail with no custom message → the built-in default, localized (not the English default in ru).
if ok, r := PurchaseGate(OverrideDefault, offNoMsg, "ru"); ok || r != defaultUnavailable("ru") {
t.Errorf("default + off no-msg (ru) = %v/%q, want false + the ru default", ok, r)
}
// OverrideAllow bypasses the ops switch even when the rail is off.
if ok, r := PurchaseGate(OverrideAllow, offMsg, "en"); !ok || r != "" {
t.Errorf("allow + off rail = %v/%q, want true/empty (bypasses the ops switch)", ok, r)
}
// OverrideDeny blocks even when the rail is on, with a non-empty reason.
if ok, r := PurchaseGate(OverrideDeny, on, "en"); ok || r == "" {
t.Errorf("deny + on rail = %v/%q, want false + a reason", ok, r)
}
// The message falls back to the other language when only one is set.
if _, r := PurchaseGate(OverrideDefault, RailAvailability{MessageEN: "OnlyEN"}, "ru"); r != "OnlyEN" {
t.Errorf("off rail ru with only EN msg = %q, want OnlyEN (fallback)", r)
}
}
@@ -0,0 +1,63 @@
package payments
import (
"context"
"fmt"
"github.com/google/uuid"
)
// CanPurchase reports whether an account may open a purchase order on rail, combining the account's
// per-account override with the rail's operational switch (PurchaseGate). It is the operational
// availability layer only — the caller still enforces the security gates (trusted platform, the D36
// email anchor, the VK-iOS spend freeze, the min client version). On a block, reason is localized to
// lang for the user; err is only a store failure.
func (s *Service) CanPurchase(ctx context.Context, accountID uuid.UUID, rail, lang string) (ok bool, reason string, err error) {
ov, err := s.store.purchaseOverride(ctx, accountID)
if err != nil {
return false, "", err
}
avail, err := s.store.railAvailability(ctx, rail)
if err != nil {
return false, "", err
}
ok, reason = PurchaseGate(ov, avail, lang)
return ok, reason, nil
}
// RailStatuses returns every known rail's operational status for the admin editor, filling a rail
// with no stored row with the fail-open enabled default so the editor always shows one row per rail.
func (s *Service) RailStatuses(ctx context.Context) (map[string]RailAvailability, error) {
stored, err := s.store.allRailStatus(ctx)
if err != nil {
return nil, err
}
out := make(map[string]RailAvailability, len(KnownRails))
for _, rail := range KnownRails {
if a, ok := stored[rail]; ok {
out[rail] = a
} else {
out[rail] = RailAvailability{Enabled: true}
}
}
return out, nil
}
// SetRailStatus upserts a rail's operational status from the admin editor. It rejects an unknown rail
// key so a typo cannot create a dead row.
func (s *Service) SetRailStatus(ctx context.Context, rail string, a RailAvailability) error {
if !isKnownRail(rail) {
return fmt.Errorf("payments: unknown rail %q", rail)
}
return s.store.setRailStatus(ctx, rail, a, s.clock())
}
// PurchaseOverrideFor returns an account's purchase override (OverrideDefault when none is set).
func (s *Service) PurchaseOverrideFor(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
return s.store.purchaseOverride(ctx, accountID)
}
// SetPurchaseOverride sets or clears an account's purchase override (OverrideDefault deletes the row).
func (s *Service) SetPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride) error {
return s.store.setPurchaseOverride(ctx, accountID, ov, s.clock())
}
@@ -0,0 +1,96 @@
package payments
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// railAvailability reads a rail's operational availability. A missing row is fail-open: enabled with
// no message, so payments stay on unless an operator explicitly disabled the rail.
func (s *Store) railAvailability(ctx context.Context, rail string) (RailAvailability, error) {
var a RailAvailability
err := s.db.QueryRowContext(ctx,
`SELECT enabled, message_ru, message_en FROM payments.rail_status WHERE rail = $1`, rail).
Scan(&a.Enabled, &a.MessageRU, &a.MessageEN)
if errors.Is(err, sql.ErrNoRows) {
return RailAvailability{Enabled: true}, nil
}
if err != nil {
return RailAvailability{}, fmt.Errorf("payments: read rail status %q: %w", rail, err)
}
return a, nil
}
// allRailStatus reads every stored rail-status row for the admin editor, keyed by rail. Rails with
// no row are absent (the caller fills them with the fail-open enabled default).
func (s *Store) allRailStatus(ctx context.Context) (map[string]RailAvailability, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT rail, enabled, message_ru, message_en FROM payments.rail_status`)
if err != nil {
return nil, fmt.Errorf("payments: read rail statuses: %w", err)
}
defer rows.Close()
out := make(map[string]RailAvailability)
for rows.Next() {
var rail string
var a RailAvailability
if err := rows.Scan(&rail, &a.Enabled, &a.MessageRU, &a.MessageEN); err != nil {
return nil, fmt.Errorf("payments: scan rail status: %w", err)
}
out[rail] = a
}
return out, rows.Err()
}
// setRailStatus upserts a rail's operational status (admin).
func (s *Store) setRailStatus(ctx context.Context, rail string, a RailAvailability, now time.Time) error {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO payments.rail_status (rail, enabled, message_ru, message_en, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (rail) DO UPDATE SET enabled = $2, message_ru = $3, message_en = $4, updated_at = $5`,
rail, a.Enabled, a.MessageRU, a.MessageEN, now); err != nil {
return fmt.Errorf("payments: set rail status %q: %w", rail, err)
}
return nil
}
// purchaseOverride reads an account's purchase override; a missing row is OverrideDefault.
func (s *Store) purchaseOverride(ctx context.Context, accountID uuid.UUID) (PurchaseOverride, error) {
var allow bool
err := s.db.QueryRowContext(ctx,
`SELECT allow FROM payments.account_payment_override WHERE account_id = $1`, accountID).Scan(&allow)
if errors.Is(err, sql.ErrNoRows) {
return OverrideDefault, nil
}
if err != nil {
return OverrideDefault, fmt.Errorf("payments: read purchase override %s: %w", accountID, err)
}
if allow {
return OverrideAllow, nil
}
return OverrideDeny, nil
}
// setPurchaseOverride upserts an account's override, or deletes the row for OverrideDefault (the
// default state is the absence of a row).
func (s *Store) setPurchaseOverride(ctx context.Context, accountID uuid.UUID, ov PurchaseOverride, now time.Time) error {
if ov == OverrideDefault {
if _, err := s.db.ExecContext(ctx,
`DELETE FROM payments.account_payment_override WHERE account_id = $1`, accountID); err != nil {
return fmt.Errorf("payments: clear purchase override %s: %w", accountID, err)
}
return nil
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO payments.account_payment_override (account_id, allow, updated_at) VALUES ($1, $2, $3)
ON CONFLICT (account_id) DO UPDATE SET allow = $2, updated_at = $3`,
accountID, ov == OverrideAllow, now); err != nil {
return fmt.Errorf("payments: set purchase override %s: %w", accountID, err)
}
return nil
}
@@ -0,0 +1,26 @@
-- Payment availability controls (D45/D46): a per-rail operational kill switch with a localized message,
-- and a per-account purchase override. Both let an operator disable payments live from the admin —
-- a whole rail/channel, or one account — and explain why to the user on a purchase attempt. Additive
-- (new tables) — applies forward via goose with no data rewrite (no contour wipe); an image rollback
-- ignores both tables. Fail-open by design: a rail with no row is enabled; an account with no row
-- follows the rail switch.
-- +goose Up
CREATE TABLE payments.rail_status (
rail text PRIMARY KEY,
enabled boolean NOT NULL DEFAULT true,
message_ru text NOT NULL DEFAULT '',
message_en text NOT NULL DEFAULT '',
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE payments.account_payment_override (
account_id uuid PRIMARY KEY,
allow boolean NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE payments.account_payment_override;
DROP TABLE payments.rail_status;
@@ -51,7 +51,16 @@ func (s *Server) consoleCatalog(c *gin.Context) {
s.consoleError(c, err)
return
}
rails, err := s.payments.RailStatuses(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
for _, rail := range payments.KnownRails {
a := rails[rail]
view.Rails = append(view.Rails, adminconsole.RailStatusRow{Rail: rail, Enabled: a.Enabled, MessageRU: a.MessageRU, MessageEN: a.MessageEN})
}
for _, p := range products {
view.Products = append(view.Products, catalogRow(p))
}
@@ -72,6 +81,23 @@ func (s *Server) consoleSetReward(c *gin.Context) {
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.
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
@@ -61,6 +61,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/users/:id/grant", s.consoleGrant)
gm.POST("/users/:id/grant-product", s.consoleGrantProduct)
gm.POST("/users/:id/refund", s.consoleRefund)
gm.POST("/users/:id/purchase-override", s.consoleSetPurchaseOverride)
gm.POST("/users/:id/delete", s.consoleDeleteUser)
gm.GET("/reasons", s.consoleReasons)
gm.POST("/reasons", s.consoleCreateReason)
@@ -113,6 +114,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.GET("/catalog", s.consoleCatalog)
gm.POST("/catalog", s.consoleCreateProduct)
gm.POST("/catalog/reward", s.consoleSetReward)
gm.POST("/catalog/rail-status", s.consoleSetRailStatus)
gm.GET("/catalog/:id", s.consoleCatalogDetail)
gm.POST("/catalog/:id", s.consoleUpdateProduct)
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
@@ -461,6 +463,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
s.log.Warn("console: account statement failed", zap.String("account", id.String()), zap.Error(err))
}
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)
}
@@ -489,6 +494,47 @@ func financeView(stmt payments.Statement) adminconsole.FinanceView {
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
// user card renders.
func relationRows(rels []social.AdminRelation) []adminconsole.RelationRow {
+8 -4
View File
@@ -160,16 +160,20 @@ type guestAuthRequest struct {
// 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.
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,
// seeding its time zone from the optional detected browser offset.
// seeding its time zone from the optional detected browser offset and its interface
// language from the optional detected locale.
func (s *Server) handleGuestAuth(c *gin.Context) {
// The body is optional: an absent or malformed one simply yields no time-zone seed
// (the account keeps the UTC default), so a bind error must not fail the bootstrap.
// The body is optional: an absent or malformed one simply yields no time-zone/language seed
// (the account keeps the UTC / 'en' defaults), so a bind error must not fail the bootstrap.
var req guestAuthRequest
_ = c.ShouldBindJSON(&req)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ, req.Language)
if err != nil {
s.abortErr(c, err)
return
@@ -67,6 +67,21 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
s.abortErr(c, err)
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 {
case payments.SourceDirect:
shop, ok := s.robokassa.Shop(cxt.Subtype)
+6
View File
@@ -285,6 +285,12 @@ func (s *Server) handleLinkVKMerge(c *gin.Context) {
// or a completed link (the active account's refreshed profile).
func (s *Server) confirmResultResponse(c *gin.Context, activeID uuid.UUID, res link.ConfirmResult) linkResultResponse {
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 {
out := linkResultResponse{Status: "merge_required", SecondaryUserID: res.SecondaryID.String()}
if acc, err := s.accounts.GetByID(ctx, res.SecondaryID); err == nil {
+11 -7
View File
@@ -98,7 +98,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `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. |
| `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. |
| `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. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
@@ -176,10 +176,14 @@ 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
default — a missing value fails loudly instead of baking a stale tag.
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
Bumping `DICT_VERSION` and **redeploying** takes a live contour/prod to the new dictionary:
on boot the backend **delivers** the build's version onto the volume (add-only, into
`BACKEND_DICT_DIR/<version>/` from the unshadowed `BACKEND_DICT_SEED_DIR`) and **activates** it,
so new games pin it while in-flight games keep the version they started on. The `.seed_version`
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
@@ -257,8 +261,8 @@ web/prod rollout — a signed APK uploaded to RuStore by hand. The build/release
the SDK is missing or unreadable.
- **Release keystore** (create once, **back up off-host** — losing it means the app can never be updated):
`keytool -genkeypair -v -keystore erudit-release.jks -alias erudit -keyalg RSA -keysize 4096 -validity 10000`,
then set the Gitea **secrets** `ANDROID_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
then set the Gitea **secrets** `ANDROID_RUSTORE_KEYSTORE_BASE64` (`base64 -w0 erudit-release.jks`),
`ANDROID_RUSTORE_KEYSTORE_PASSWORD`, `ANDROID_RUSTORE_KEY_ALIAS`, `ANDROID_RUSTORE_KEY_PASSWORD`. Set the `ANDROID_RUSTORE_URL`
variable to the store listing once published (empty until then — the in-app update button no-ops).
- **Wire-break discipline:** the prod deploy that ships an incompatible wire change **also** bumps
`GATEWAY_MIN_CLIENT_VERSION` to that release (see Optional variables), so an old installed APK is turned
+21 -16
View File
@@ -446,22 +446,27 @@ Key points:
version while new games use the new one. A restart re-loads every resident
version via `engine.OpenWithVersions` (the flat seed plus each subdirectory,
skipping the `.staging/` upload area) and restores the active pointer from
`dictionary_state`. The volume preserves uploaded versions across redeploys;
once seeded it is not re-seeded, so after bootstrap dictionary changes go through
the console rather than a rebuild. Because the flat DAWGs carry no embedded
version, `OpenWithVersions` records the version the flat directory was first seeded
at in a `.seed_version` marker on the volume and treats that marker as
**authoritative** (the **seed-drift guard**): on an already-seeded volume a later
`BACKEND_DICT_VERSION` is ignored, so a bumped build seed cannot relabel the
already-seeded bytes — which would otherwise silently serve the wrong dictionary
and void games pinned to the prior label. A running contour therefore moves to a
new release **through the console** (the prior version stays resident, so its games
keep replaying), and `DICT_VERSION` is the seed for a **fresh** volume only:
bumping it on a live contour is a harmless no-op that takes effect on the next
fresh volume. Set it per contour from the deploy's `TEST_`/`PROD_DICT_VERSION`.
(The dictionaries ship as a versioned **release artifact** from the
`scrabble-dictionary` repo; the build's `DICT_VERSION` selects
only the seed.)
`dictionary_state`. The volume preserves uploaded versions across redeploys, so
in-flight games keep replaying on the version they pin. The build's
`BACKEND_DICT_VERSION` is **deploy-authoritative**: on boot `engine.DeliverVersion`
copies the image's DAWGs — kept unshadowed at `BACKEND_DICT_SEED_DIR`, since the
volume mount hides the image's `BACKEND_DICT_DIR` copy — into
`BACKEND_DICT_DIR/<version>/` when that version is not already resident (**add-only**:
the flat seed and prior uploads are never touched), and `InitActiveVersion` makes it
the active version. So a redeploy that bumped `DICT_VERSION` moves new games to the
new dictionary automatically, while old games stay on theirs — no console step. The
one exception: a version installed out-of-band through the console that is **newer**
than the build version and still resident is kept, so a restart on an older image never
downgrades a hotfix (versions compare numerically). Because the flat DAWGs carry no
embedded version, `OpenWithVersions` records the version the flat directory was first
seeded at in a `.seed_version` marker and treats it as authoritative for **that flat
directory** (the **seed-drift guard**): a later `BACKEND_DICT_VERSION` never relabels
the already-seeded flat bytes — it is delivered as a **new subdirectory** instead — so
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),
`Solver.ValidatePlay` and `Solver.ScorePlay`; board mutation uses
`scrabble.Apply`. The engine adds its own deterministic, seeded tile **bag**
+10 -7
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
⚙️ 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,
🛟 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
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
@@ -117,12 +117,15 @@ 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
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
identity already belongs to another account, the player is shown an explicit,
**irreversible** confirmation and the two accounts are merged into the one they are
using (statistics summed, games and friends transferred, duplicates removed) — except
when a guest links an identity that already has a durable account, where the durable
account is kept and the guest's games move into it. A merge is blocked only while the
two accounts share a game still in progress.
identity already belongs to another account, the two accounts are merged into one
(statistics summed, games and friends transferred, wallet folded, duplicates removed).
A **durable** initiator is shown an explicit, **irreversible** confirmation first —
consolidating two real accounts is consequential. A **guest** initiator is not: the
durable account that owns the identity is kept, and the ephemeral guest — with its games,
wallet and stats folded in — is retired **seamlessly**, so from the player's side it is
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
Telegram (a login-widget popup) or VK (VK ID web login — a redirect to VK's sign-in and
+9 -7
View File
@@ -52,7 +52,7 @@ Telegram-бот, который документы указывают как к
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
@@ -121,12 +121,14 @@ Telegram держит **единого бота**: все игроки поль
привязывает email (по confirm-коду) или свой Telegram (через веб-вход); гость,
привязавший первую личность, становится постоянным аккаунтом. Факт «личность уже
занята» не раскрывается до проверки кода/входа. Если привязываемая личность уже
принадлежит другому аккаунту, игроку показывают явное **необратимое**
подтверждение, и два аккаунта сливаются в тот, под которым он сейчас работает
(статистика суммируется, игры и друзья переносятся, дубликаты убираются), — кроме
случая, когда гость привязывает личность с уже существующим постоянным аккаунтом:
тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние
запрещено, только пока у аккаунтов есть общая незавершённая игра.
принадлежит другому аккаунту, два аккаунта сливаются в один (статистика суммируется,
игры и друзья переносятся, кошелёк складывается, дубликаты убираются). **Постоянному**
инициатору сначала показывают явное **необратимое** подтверждение — объединение двух
настоящих аккаунтов важно. **Гостю** — нет: сохраняется постоянный аккаунт-владелец
личности, а эфемерный гость (с его играми, кошельком и статистикой) ретайрится
**бесшовно**, так что со стороны игрока это просто вход в свой аккаунт (приложение
переключается на него без запроса). Слияние запрещено, только пока у аккаунтов есть
общая незавершённая игра; гостя тогда просят сперва доиграть эту общую партию.
В профиле перечислены **способы входа** аккаунта. В вебе игрок может добавить
Telegram (попап логин-виджета) или VK (веб-вход VK ID — редирект на страницу входа VK и
+4 -1
View File
@@ -54,7 +54,10 @@ already fill the 108 dp canvas (foreground inside the 61 % safe zone, background
full-bleed), so they must be placed with **no inset**, and we add the **monochrome**
themed layer, neither of which `capacitor-assets` does. Per density mdpi→xxxhdpi (+ldpi):
- `mipmap-*/ic_launcher.png` / `ic_launcher_round.png` — legacy square / round (composite)
- `mipmap-*/ic_launcher.png` — legacy square (< API 26): the **full-bleed master** (large
mark), so old square launchers do not shrink it into the safe zone
- `mipmap-*/ic_launcher_round.png` — legacy round (< API 26): the safe-zone **composite**,
circle-clipped (a round mask would clip the master's corner ✻)
- `mipmap-*/ic_launcher_foreground.png` + `ic_launcher_background.png` — adaptive layers
- `mipmap-*/ic_launcher_monochrome.png` — themed layer
- `mipmap-anydpi-v26/ic_launcher.xml` + `ic_launcher_round.xml`**no `<inset>`**, with
+6 -5
View File
@@ -123,12 +123,13 @@ 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
layer, so the sacrificial outer ring is just more wood.
- **Foreground** — **only the «Э» + ✻**, transparent, **re-placed** to sit safely inside
the mask:
- «Э»: box height **53.9 %**, box center **52.3 % / 49.5 %**.
- ✻: outer diameter **13.2 %** (smaller than the master's), center **76.3 % / 72.7 %**,
the mask. The whole mark is the master's optical placement (nudged right 8 % / down 3 %)
scaled **0.75 about the icon centre****25 % smaller**, its «Э» ↔ ✻ arrangement
unchanged — so the ✻ that used to graze the round mask now sits well inside the **61 %
safe zone** with margin to spare:
- «Э»: box height **40.4 %**, box center **51.8 % / 49.6 %**.
- ✻: outer diameter **9.9 %** (smaller than the master's), center **69.7 % / 67.0 %**,
tucked closer to the letter.
- 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.
+6
View File
@@ -66,6 +66,12 @@ Standalone apps (Android/iOS) sign in by email only, so a direct purchase always
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
Do not conflate these — they use different keys:
+17 -2
View File
@@ -268,6 +268,19 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
`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.
## Заметки к оформлению документов
@@ -283,14 +296,16 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
ведёт к пропускам. Текст — только для фиксации решённого и пояснений. Усилить
feedback-память `prefer-interview-mode` после plan mode.
## Все развилки закрыты (D1-D44)
## Все развилки закрыты (D1-D46)
Интервью завершено. Дальше — оформление документов и реализация по релизам.
**Дополнение 2026-07-14 (интервью с владельцем).** D41 ревизована (владелец переходит на
ИП: 54-ФЗ через облачную кассу Robokassa вместо авточека НПД); добавлены D42-D44 — сплит
`direct`-рельса Robokassa на магазины по каналу (web/android; ios позже) при едином кошельке
`direct` и входе email-only в standalone-приложениях. Реализация — новый этап E10 в `PLAN.md`.
`direct` и входе email-only в standalone-приложениях (этап E10 в `PLAN.md`). Плюс D45-D46 —
рубильник платежей per-rail + локализованное сообщение + per-user override (этап E11). Фискализация
(B4) — кабинетная на стороне Robokassa, itemized-код не делаем (решение владельца).
## План внедрения (черновик PLAN.md — «слоями»)
+6
View File
@@ -66,6 +66,12 @@ Robokassa **на канал** — `web` и `android` (RuStore), позже `ios`
у 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. Три операции, которые нельзя путать
Не смешивать — у них разные ключи:
+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
**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
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;
@@ -284,7 +284,7 @@ e2e and the screenshots.
opens a dialog — pick tiles to **Exchange N**, or pick
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
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
**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
+4 -3
View File
@@ -312,11 +312,12 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
}
// GuestAuth provisions a guest account and mints a session, seeding its time zone
// from browserTz (the client's detected "±HH:MM" UTC offset).
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype string) (SessionResp, error) {
// from browserTz (the client's detected "±HH:MM" UTC offset) and its interface
// language from language (the client's detected locale; best-effort).
func (c *Client) GuestAuth(ctx context.Context, browserTz, subtype, language string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
map[string]string{"browser_tz": browserTz, "subtype": subtype}, &out)
map[string]string{"browser_tz": browserTz, "subtype": subtype, "language": language}, &out)
return out, err
}
+47
View File
@@ -0,0 +1,47 @@
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
@@ -0,0 +1,54 @@
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)
}
}
+2 -1
View File
@@ -302,7 +302,7 @@ func (s *Server) HTTPHandler() http.Handler {
// 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);
// the h2c server carries explicit stream/idle sizing.
return h2c.NewHandler(s.abuseGuard(maxBodyHandler(s.maxBodyBytes, mux)), &http2.Server{
return h2c.NewHandler(s.abuseGuard(withNativeCORS(maxBodyHandler(s.maxBodyBytes, mux))), &http2.Server{
MaxConcurrentStreams: h2cMaxConcurrentStreams,
IdleTimeout: h2cIdleTimeout,
})
@@ -472,6 +472,7 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
return connect.NewResponse(&edgev1.ExecuteResponse{
RequestId: req.Msg.GetRequestId(),
ResultCode: code,
Message: transcode.DomainMessage(err),
}), nil
}
s.log.Error("execute failed", zap.String("message_type", msgType), zap.Error(err))
+14 -2
View File
@@ -189,6 +189,17 @@ func (r *Registry) Lookup(messageType string) (Op, bool) {
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
// envelope, reporting false for an unexpected error the caller should treat as a
// transport-level internal failure.
@@ -259,13 +270,14 @@ func authGuestHandler(backend *backendclient.Client) Handler {
// 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
// than panicking in GetRootAs* on a zero-length buffer).
var browserTz, subtype string
var browserTz, subtype, locale string
if len(req.Payload) > 0 {
g := fb.GetRootAsGuestLoginRequest(req.Payload, 0)
browserTz = string(g.BrowserTz())
subtype = string(g.Subtype())
locale = string(g.Locale())
}
sess, err := backend.GuestAuth(ctx, browserTz, subtype)
sess, err := backend.GuestAuth(ctx, browserTz, subtype, locale)
if err != nil {
return nil, err
}
+18 -6
View File
@@ -94,10 +94,14 @@ func (x *ExecuteRequest) GetRequestId() string {
// ExecuteResponse is the unary reply. result_code is "ok" on success or a stable
// error code; payload is the FlatBuffers-encoded response body (empty on error).
type ExecuteResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
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"`
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
sizeCache protoimpl.SizeCache
}
@@ -153,6 +157,13 @@ func (x *ExecuteResponse) GetPayload() []byte {
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
// the Authorization header.
type SubscribeRequest struct {
@@ -262,13 +273,14 @@ const file_edge_v1_edge_proto_rawDesc = "" +
"\fmessage_type\x18\x01 \x01(\tR\vmessageType\x12\x18\n" +
"\apayload\x18\x02 \x01(\fR\apayload\x12\x1d\n" +
"\n" +
"request_id\x18\x03 \x01(\tR\trequestId\"k\n" +
"request_id\x18\x03 \x01(\tR\trequestId\"\x85\x01\n" +
"\x0fExecuteResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x1f\n" +
"\vresult_code\x18\x02 \x01(\tR\n" +
"resultCode\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\"\x12\n" +
"\apayload\x18\x03 \x01(\fR\apayload\x12\x18\n" +
"\amessage\x18\x04 \x01(\tR\amessage\"\x12\n" +
"\x10SubscribeRequest\"P\n" +
"\x05Event\x12\x12\n" +
"\x04kind\x18\x01 \x01(\tR\x04kind\x12\x18\n" +
+4
View File
@@ -35,6 +35,10 @@ message ExecuteResponse {
string request_id = 1;
string result_code = 2;
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.0 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: 9.6 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.8 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: 15 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+11
View File
@@ -24,6 +24,17 @@ 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) {
delete rootProject.buildDir
}
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 24
minSdkVersion = 29
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 24 KiB

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

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

+17 -8
View File
@@ -10,6 +10,7 @@
import Rack from './Rack.svelte';
import { gateway } from '../lib/gateway';
import { gameSource, localSource, isLocalGameId } from '../lib/gamesource';
import { localGuestId } from '../lib/localguest';
import { notePreviewLocal, notePreviewNetwork } from '../lib/localeval-metrics';
import { navigate } from '../lib/router.svelte';
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
@@ -1442,21 +1443,29 @@
// seatName renders a seat's name: "you" for the viewer, the localized "searching for
// opponent" placeholder for an open game's still-empty seat (no account), otherwise the
// display name.
// isMe reports whether a seat belongs to the viewer — matched against the server account id OR
// the device-local guest id, the same rule the lobby uses for "my games". A device-local game
// (vs_ai / hotseat) is seated under the local guest id when created offline, and its human seat
// must still read "You" after a merge switches the active account to a durable id; without the
// local-guest arm the seat matched neither and a vs_ai game showed BOTH seats as robots.
function isMe(accountId: string | undefined): boolean {
return !!accountId && (accountId === app.session?.userId || accountId === localGuestId());
}
function seatName(s: { accountId: string; displayName: string } | undefined): string {
if (!s) return '';
if (s.accountId === app.session?.userId) return t('common.you');
if (isMe(s.accountId)) return t('common.you');
// An honest-AI game shows the robot opponent as 🤖, never its (pooled human-like) name.
if (view?.game.vsAi) return '🤖';
if (!s.accountId) return t('game.searchingForOpponent');
return s.displayName;
}
// turnLabel is the under-board status when it is not the viewer's turn: the opponent's name
// once they have joined, or a generic "opponent's turn" while the seat is still empty (waiting).
// turnLabel is the under-board status when it is not the viewer's turn, for online PvP and
// vs_ai (the hotseat branch of the turn strip shows the seat's own name instead): a generic
// "opponent's turn" rather than the opponent's name or the robot mark, so whose turn it is
// reads the same everywhere. The seat row below still names each player.
function turnLabel(): string {
if (view?.game.vsAi) return '🤖';
const s = view?.game.seats[view?.game.toMove ?? -1];
if (s && s.accountId && s.accountId !== app.session?.userId) return s.displayName;
return t('game.opponentsTurn');
}
@@ -1736,7 +1745,7 @@
idle gate while it is closed; tapping then only explains when it opens (doHint), and the lock
lifts live at the unlock moment. -->
<button class="tab" disabled={busy || !isMyTurn || !netReady} onclick={doHint}>
<span class="sq" data-coach="game-hints">🛟{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="sq" data-coach="game-hints">{#if hintGated}<span class="lock" aria-hidden="true">🔒</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</button>
{:else}
@@ -1746,7 +1755,7 @@
disabled={busy || !isMyTurn || !netReady || hintCount <= 0}
onconfirm={doHint}
>
<span class="sq" data-coach="game-hints">🛟{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="sq" data-coach="game-hints">{#if hintCount > 0}<span class="badge">{hintCount}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{/if}
+10 -1
View File
@@ -17,7 +17,7 @@ import type { Message } from "@bufbuild/protobuf";
* Describes the file edge/v1/edge.proto.
*/
export const file_edge_v1_edge: GenFile = /*@__PURE__*/
fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJLCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMIhIKEFN1YnNjcmliZVJlcXVlc3QiOAoFRXZlbnQSDAoEa2luZBgBIAEoCRIPCgdwYXlsb2FkGAIgASgMEhAKCGV2ZW50X2lkGAMgASgJMqUBCgdHYXRld2F5Ek4KB0V4ZWN1dGUSIC5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXF1ZXN0GiEuc2NyYWJibGUuZWRnZS52MS5FeGVjdXRlUmVzcG9uc2USSgoJU3Vic2NyaWJlEiIuc2NyYWJibGUuZWRnZS52MS5TdWJzY3JpYmVSZXF1ZXN0Ghcuc2NyYWJibGUuZWRnZS52MS5FdmVudDABQidaJXNjcmFiYmxlL2dhdGV3YXkvcHJvdG8vZWRnZS92MTtlZGdldjFiBnByb3RvMw");
fileDesc("ChJlZGdlL3YxL2VkZ2UucHJvdG8SEHNjcmFiYmxlLmVkZ2UudjEiSwoORXhlY3V0ZVJlcXVlc3QSFAoMbWVzc2FnZV90eXBlGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEgoKcmVxdWVzdF9pZBgDIAEoCSJcCg9FeGVjdXRlUmVzcG9uc2USEgoKcmVxdWVzdF9pZBgBIAEoCRITCgtyZXN1bHRfY29kZRgCIAEoCRIPCgdwYXlsb2FkGAMgASgMEg8KB21lc3NhZ2UYBCABKAkiEgoQU3Vic2NyaWJlUmVxdWVzdCI4CgVFdmVudBIMCgRraW5kGAEgASgJEg8KB3BheWxvYWQYAiABKAwSEAoIZXZlbnRfaWQYAyABKAkypQEKB0dhdGV3YXkSTgoHRXhlY3V0ZRIgLnNjcmFiYmxlLmVkZ2UudjEuRXhlY3V0ZVJlcXVlc3QaIS5zY3JhYmJsZS5lZGdlLnYxLkV4ZWN1dGVSZXNwb25zZRJKCglTdWJzY3JpYmUSIi5zY3JhYmJsZS5lZGdlLnYxLlN1YnNjcmliZVJlcXVlc3QaFy5zY3JhYmJsZS5lZGdlLnYxLkV2ZW50MAFCJ1olc2NyYWJibGUvZ2F0ZXdheS9wcm90by9lZGdlL3YxO2VkZ2V2MWIGcHJvdG8z");
/**
* ExecuteRequest is the unary envelope. message_type selects the operation;
@@ -71,6 +71,15 @@ export type ExecuteResponse = Message<"scrabble.edge.v1.ExecuteResponse"> & {
* @generated from field: bytes payload = 3;
*/
payload: Uint8Array;
/**
* 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.
*
* @generated from field: string message = 4;
*/
message: string;
};
/**
+16 -1
View File
@@ -662,7 +662,18 @@ async function reconcileServerGuest(): Promise<void> {
*/
export async function applyLinkResult(r: LinkResult): Promise<void> {
if (r.session && r.session.token) {
// A guest initiator's merge switches the session to the surviving durable account. Its
// device-local games (vs_ai / hotseat) were seated under the retired account's id, so
// re-point them to the survivor: the lobby and the game header identify "me" by the active
// account, and without this the merged-away game shows every seat as an opponent (the
// in-game turn logic is seat-index based, so it still plays — the summary just misreads).
const retiredId = app.session?.userId;
await adoptSession(r.session);
const survivorId = app.session?.userId;
if (retiredId && survivorId && retiredId !== survivorId) {
const { repointLocalGameSeats } = await import('./localgame/store');
await repointLocalGameSeats(retiredId, survivorId);
}
return;
}
app.profile = await gateway.profileGet();
@@ -971,7 +982,11 @@ export async function bootstrap(): Promise<void> {
// to online when the network returns. Web-only reaches here (the Mini-App branches returned above),
// so isStandalone gates it.
const cachedProfile = await loadProfile();
const canOffline = !!cachedProfile && !vkcb && isStandalone() && !!cachedProfile.email;
// A native app is offline-first, so a native launch — even a guest with no email — must fall back
// to the cached session's offline lobby rather than hang ~25 s on adoptSession's retrying profile
// fetch when the device is offline (e.g. airplane mode). Web keeps the installed-PWA + email gate.
const nativeChannel = clientChannel() === 'android' || clientChannel() === 'ios';
const canOffline = !vkcb && (nativeChannel || (!!cachedProfile && isStandalone() && !!cachedProfile.email));
if (canOffline) {
const interfaceOffline = typeof navigator !== 'undefined' && navigator.onLine === false;
gateway.setToken(saved.token);
+2 -1
View File
@@ -91,7 +91,7 @@ export const en = {
'onboarding.lobbyNew': 'Practise against the AI', // 🎲
'onboarding.gameHeader': 'Move history, chat and word check', // scoreboard strip
'onboarding.gameTurn': 'Pass a turn, swap tiles', // 🔄
'onboarding.gameHints': 'Available hints', // 🛟
'onboarding.gameHints': 'Available hints', //
'onboarding.gameShuffle': 'Shuffle your tiles', // 🔀
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
@@ -312,6 +312,7 @@ export const en = {
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.nudge_too_soon': "Please don't rush your opponent so often.",
'error.chat_not_your_turn': 'You can chat only on your turn.',
'error.merge_active_game_conflict': 'You have an unfinished game with that account. Finish it, then sign in again.',
'error.chat_already_sent': 'You can send only one message per turn.',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
+2 -1
View File
@@ -91,7 +91,7 @@ export const ru: Record<MessageKey, string> = {
'onboarding.lobbyNew': 'Потренируйтесь с ИИ', // 🎲
'onboarding.gameHeader': 'История ходов, чат и проверка слова', // шапка над доской
'onboarding.gameTurn': 'Пропуск хода, обмен фишек', // 🔄
'onboarding.gameHints': 'Доступные подсказки', // 🛟
'onboarding.gameHints': 'Доступные подсказки', //
'onboarding.gameShuffle': 'Встряхните фишки', // 🔀
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
@@ -312,6 +312,7 @@ export const ru: Record<MessageKey, string> = {
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.nudge_too_soon': 'Не стоит торопить соперника так часто.',
'error.chat_not_your_turn': 'Писать в чат можно только в свой ход.',
'error.merge_active_game_conflict': 'У вас есть незавершённая партия с этим аккаунтом. Доиграйте её и войдите снова.',
'error.chat_already_sent': 'За один ход можно отправить только одно сообщение.',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
+20
View File
@@ -81,6 +81,26 @@ export async function listLocalGames(): Promise<LocalGameRecord[]> {
}
}
/** repointLocalGameSeats rewrites the account id on every stored game's seats from oldId to
* newId. It is run when an account merge retires the account a device-local game was seated
* under (a guest that logged into a durable account): the surviving account then owns the
* games, so the lobby and the game header identify "me" again without it the human seat no
* longer matches the active account and the game shows every seat as an opponent. Best-effort;
* the records read from IndexedDB are plain objects, so mutating and re-saving them is safe. */
export async function repointLocalGameSeats(oldId: string, newId: string): Promise<void> {
if (!oldId || !newId || oldId === newId) return;
for (const g of await listLocalGames()) {
let touched = false;
for (const s of g.seats) {
if (s.accountId === oldId) {
s.accountId = newId;
touched = true;
}
}
if (touched) await saveLocalGame(g);
}
}
/** deleteLocalGame removes a game record, swallowing any failure (best-effort). */
export async function deleteLocalGame(id: string): Promise<void> {
const db = openDb();
+4 -1
View File
@@ -175,7 +175,10 @@ function applyEffect(effect: Effect): void {
* poll immediately (the native reconcile) rather than after the first interval.
*/
export function bootOffline(kickNow = false): void {
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0 };
// provisional: this offline is an assumption, not an observation — the first probe result confirms
// it (silent heal if reachable, so a first launch that had connectivity does not flash a spurious
// "back online" toast) or turns it into a real offline (see the reducer).
snap = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };
arm(kickNow ? 0 : OFFLINE_POLL_MS);
}
+28
View File
@@ -248,3 +248,31 @@ describe('reduce — purity', () => {
expect(prev).toEqual({ state: 'connecting', fails: 1, connectingSince: 5 });
});
});
// A boot-assumed (provisional) offline — bootOffline sets this — heals SILENTLY on its first
// successful probe (a first launch that actually had connectivity must not flash "back online"),
// while a failed probe confirms it as a real offline whose later recovery does toast.
describe('provisional (boot-assumed) offline', () => {
const bootOffline: NetSnapshot = { state: 'offlineNoNetwork', fails: 0, connectingSince: 0, provisional: true };
it('heals to online with NO toast when the first probe succeeds', () => {
const r = reduce(bootOffline, e('probeOk'), cfg);
expect(r.next.state).toBe('online');
expect(r.effects).toEqual([]); // no "back online" toast — it was never really offline
});
it('clears provisional on a failed probe, then toasts on the eventual recovery', () => {
const failed = reduce(bootOffline, e('probeFailed'), cfg);
expect(failed.next.state).toBe('offlineNoNetwork');
expect(failed.next.provisional).toBe(false);
expect(failed.effects).toEqual([]);
const healed = reduce(failed.next, e('probeOk'), cfg);
expect(healed.next.state).toBe('online');
expect(healed.effects).toEqual([{ kind: 'toast', toast: 'online' }]); // now a real recovery
});
it('a non-provisional (observed) offline still toasts on recovery', () => {
const r = reduce(offline, e('probeOk'), cfg);
expect(r.effects).toEqual([{ kind: 'toast', toast: 'online' }]);
});
});
+18 -3
View File
@@ -74,6 +74,14 @@ export interface NetSnapshot {
* it (0 while not `connecting`).
*/
connectingSince: number;
/**
* provisional marks an `offlineNoNetwork` that was ASSUMED at boot (bootOffline), not observed from
* a real onlineoffline transition. The first probe confirms it: a success means we were reachable
* all along, so the machine heals to online SILENTLY (no "back online" toast there was nothing to
* come back from); a failure clears the flag, turning it into a confirmed offline whose later
* recovery does toast. Absent/false in every non-boot snapshot.
*/
provisional?: boolean;
}
/** NetInput is an event stamped with the wall-clock time it occurred at; only the debounce branch
@@ -198,13 +206,20 @@ export function reduce(prev: NetSnapshot, ev: NetInput, cfg: NetConfig): NetResu
switch (ev.type) {
case 'callOk':
case 'probeOk':
// The probe (or a reconcile call) won — self-heal to online with a "back online" toast.
return { next: onlineSnapshot(), effects: [{ kind: 'toast', toast: 'online' }] };
// The probe (or a reconcile call) won — heal to online. A boot-assumed (provisional) offline
// that is reachable on its very first probe was never really offline, so heal SILENTLY; a
// confirmed offline gets the "back online" toast.
return { next: onlineSnapshot(), effects: prev.provisional ? [] : [{ kind: 'toast', toast: 'online' }] };
case 'callFailed':
case 'probeFailed':
// A failed probe confirms a boot-assumed offline as real (its eventual recovery will then
// toast); a non-provisional offline is unchanged.
return prev.provisional ? { next: { ...prev, provisional: false }, effects: [] } : { next: prev, effects: [] };
case 'osOnline':
// Trigger a probe; stay offline until it actually wins (a captive portal can report online).
return { next: prev, effects: [{ kind: 'startProbe' }] };
default:
// callFailed / probeFailed / osOffline (still offline), versionRecommended (no nudge): no-op.
// osOffline (still offline), versionRecommended (no nudge): no-op.
return { next: prev, effects: [] };
}
}
+3 -1
View File
@@ -118,7 +118,9 @@ export function createTransport(baseUrl: string): GatewayClient {
maintenanceRecovered();
if (res.resultCode && res.resultCode !== 'ok') {
if (res.resultCode === UPDATE_REQUIRED && !opts?.silent) reportUpdateRequired();
throw new GatewayError(res.resultCode);
// Carry the envelope's optional human-readable message (e.g. an operator's payment-unavailable
// explanation) so a caller that shows it to the user has the localized text.
throw new GatewayError(res.resultCode, res.message || undefined);
}
return res.payload;
}
+6 -3
View File
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
ALL_VARIANTS,
DEFAULT_VARIANTS,
availableVariants,
supportsMultipleWordsToggle,
multipleWordsForRequest,
@@ -16,9 +17,11 @@ describe('ALL_VARIANTS', () => {
});
describe('availableVariants', () => {
it('is ungated (all variants) for an empty or absent preference set', () => {
expect(availableVariants([])).toEqual(ALL_VARIANTS);
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
it('falls back to the Erudit-only default for an empty or absent preference set', () => {
const fallback = ALL_VARIANTS.filter((v) => DEFAULT_VARIANTS.includes(v.id));
expect(fallback.map((v) => v.id)).toEqual(['erudit_ru']);
expect(availableVariants([])).toEqual(fallback);
expect(availableVariants(undefined)).toEqual(fallback);
});
it('offers only the preferred variants', () => {
+15 -5
View File
@@ -62,13 +62,23 @@ export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru';
}
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
// they enabled in Settings). An empty or absent set is ungated (returns every variant)
// — a safety fallback; a real profile always carries at least one preference.
// DEFAULT_VARIANTS is the variant set a player sees before any preference is stored — a fresh
// or offline native client whose profile has not been synced yet (the device-local guest boots
// with no profile at all). It mirrors the backend's new-account default (the account service
// seeds Erudit only), so the local guest and a later synced account agree on what is enabled.
// Every dictionary is still bundled; the other variants are simply off until the player turns
// them on in Settings.
export const DEFAULT_VARIANTS: Variant[] = ['erudit_ru'];
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set they
// enabled in Settings). An empty or absent set falls back to DEFAULT_VARIANTS rather than every
// variant: a real profile always carries at least one preference, and a profileless client (a
// fresh offline native launch) must match the server's Erudit-only default instead of exposing
// the English game before the player opts in.
export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
const prefs = preferences ?? [];
if (prefs.length === 0) return ALL_VARIANTS;
return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
const effective = prefs.length === 0 ? DEFAULT_VARIANTS : prefs;
return ALL_VARIANTS.filter((v) => effective.includes(v.id));
}
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle

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