From 7dabcd1317555f026b97c1d8912781b384abd0d5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 24 Jun 2026 14:39:00 +0200 Subject: [PATCH 01/10] chore: track the deploy-check skill in the repo The deploy-check skill is repo-specific: it encodes this project's hard-won pre-deploy runtime constraints (distroless nonroot, edge Alt-Svc/HTTP3, caddy recreate, DICT_VERSION boot, expand-contract migrations, the Telegram permission model) and points at deploy/README.md, docs/ARCHITECTURE.md, docs/EDGE_HTTP3.md and the agent memory files. It belongs with the deploy logic it guards, not in the global config, so it travels with the repo and stays versioned alongside it. --- .claude/skills/deploy-check/SKILL.md | 89 ++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .claude/skills/deploy-check/SKILL.md diff --git a/.claude/skills/deploy-check/SKILL.md b/.claude/skills/deploy-check/SKILL.md new file mode 100644 index 0000000..1983334 --- /dev/null +++ b/.claude/skills/deploy-check/SKILL.md @@ -0,0 +1,89 @@ +--- +name: deploy-check +description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after." +--- + +# Pre-deploy runtime-constraint check + +Triggered before shipping anything that touches the deploy contour (Dockerfiles, +`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service, +edge config). The worst frictions in this repo were never logic bugs — they were +**environment mismatches that crashed a live env and forced a redesign**. Run this +list against the diff first; turn crash-and-redesign into a single pass. + +This checklist is a prompt, **not** the source of truth. The canonical detail +lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the +agent memory files referenced below — read them when an item is in play, and add a +new class here when a new incident teaches one. + +## How to run it + +1. `git diff ...HEAD --stat` to see what the change actually touches. +2. For every risk class below that the diff touches, perform the **Check** and + report PASS / FAIL with the exact file:line to fix. Skip classes the diff does + not touch — say which you skipped and why. +3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically + passed with a dead backend (it only checked static landing+gateway). Verify the + real feature live (`/readyz`, the actual flow) after deploy, not just the green. + +## Risk classes + +### 1. Container user — distroless nonroot UID 65532 +- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at + boot with "permission denied" — service images run UID 65532. +- **Check:** any new/changed mounted secret, key, or config file must be readable by + UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new + volume mounts. (memory: `distroless-nonroot-mounted-secrets`) + +### 2. Caddy header pipeline ordering +- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went + empty); it passed CI and only showed up live. +- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up` + ordering for every affected route, and test the tripwire/route on the live + contour, not just CI. + +### 3. Edge Alt-Svc / HTTP3 +- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed + (docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead + QUIC before falling back to h2 — Mini App "hangs on load". +- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if + UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`, + `docs/EDGE_HTTP3.md`) + +### 4. Prod caddy config recreate +- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change + (pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but + stayed inert until a manual `docker restart`. +- **Check:** a config-only edge change must `--force-recreate` caddy in + `prod-deploy.sh` `roll()`; never trust deploy-green for edge config. + (memory: `prod-deploy-caddy-config-recreate`) + +### 5. DICT_VERSION / dictionary boot +- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the + live env when bumped on a seeded volume; it had to be redesigned to "marker-wins". +- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must + keep marker-wins semantics and survive a seeded volume **and** an image rollback. + `DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict + goes live via the admin console upload, not a redeploy. (memory: + `dict-version-deploy-verify`, `contour-schema-change-wipe`) + +### 6. Migrations — expand-contract + rollback safety +- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB + ahead of rolled-back code). +- **Check:** migrations must be **expand-contract** (backward-compatible). A schema + change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the + **test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` + + backend restart (new code vs old persisted DB), else the contour breaks. (memory: + `contour-schema-change-wipe`) + +### 7. Telegram permission model +- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit + denies, not default-deny; inverting it broke access. +- **Check:** any change to the Telegram permission / relay logic preserves the + AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`) + +## Output + +A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact +file:line and the fix. If every touched class passes, say so plainly and name the +post-deploy live check to run (not just "CI green"). From 65c194264c0a9355b39ec9aa22c89541dbbe9153 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Sat, 27 Jun 2026 11:37:31 +0200 Subject: [PATCH 02/10] feat(vk): embed the game as a VK Mini App MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/ entry, authenticates from VK's signed launch parameters, and provisions a 'vk' platform identity — the minimum to run the game in VK test mode. - Gateway verifies the launch signature in-process (internal/vkauth: HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET, base64url) — a pure offline check, no side-service. New auth.vk op (gated on the secret), backendclient.VKAuth, /vk/ SPA mount. - Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity kind widened to include 'vk' (migration 00005, expand-contract). - UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits the name from the signed params, so the client reads it via VKWebAppGetUserInfo as an unsigned display seed. - Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through compose + .env.example + CI (TEST_) + prod-deploy (PROD_). - Admin console: surface the VK user id (link to the VK profile) next to the Telegram id on the user card. - Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK integration reference under .claude/. Signature algorithm verified against dev.vk.com plus independent Node/Python references and a %2C edge-case vector. --- .claude/vk-games-integration.md | 157 ++++++++++++++++++ .gitea/workflows/ci.yaml | 3 + .gitea/workflows/prod-deploy.yaml | 2 + backend/internal/account/account.go | 51 +++++- backend/internal/account/provision_test.go | 52 ++++++ backend/internal/adminconsole/render_test.go | 1 + .../templates/pages/user_detail.gohtml | 5 + backend/internal/adminconsole/views.go | 16 +- backend/internal/inttest/account_test.go | 47 ++++++ .../postgres/migrations/00005_vk_identity.sql | 13 ++ backend/internal/server/handlers.go | 1 + .../internal/server/handlers_admin_console.go | 3 + backend/internal/server/handlers_auth.go | 31 ++++ deploy/.env.example | 6 + deploy/caddy/Caddyfile | 4 +- deploy/docker-compose.yml | 4 + docs/ARCHITECTURE.md | 24 ++- docs/FUNCTIONAL.md | 6 +- docs/FUNCTIONAL_ru.md | 8 +- gateway/README.md | 14 +- gateway/cmd/gateway/main.go | 2 +- gateway/internal/backendclient/api.go | 17 ++ gateway/internal/config/config.go | 5 + gateway/internal/connectsrv/server.go | 15 +- gateway/internal/transcode/transcode.go | 47 +++++- .../internal/transcode/transcode_vk_test.go | 116 +++++++++++++ gateway/internal/vkauth/vkauth.go | 72 ++++++++ gateway/internal/vkauth/vkauth_test.go | 111 +++++++++++++ pkg/fbs/scrabble.fbs | 14 ++ pkg/fbs/scrabblefb/VKLoginRequest.go | 82 +++++++++ ui/package.json | 1 + ui/pnpm-lock.yaml | 22 +++ ui/src/gen/fbs/scrabblefb.ts | 1 + ui/src/gen/fbs/scrabblefb/vklogin-request.ts | 72 ++++++++ ui/src/lib/app.svelte.ts | 64 ++++++- ui/src/lib/client.ts | 4 + ui/src/lib/codec.test.ts | 8 + ui/src/lib/codec.ts | 12 ++ ui/src/lib/mock/client.ts | 3 + ui/src/lib/transport.ts | 3 + ui/src/lib/vk.test.ts | 31 ++++ ui/src/lib/vk.ts | 66 ++++++++ ui/src/screens/BootError.svelte | 9 +- 43 files changed, 1175 insertions(+), 50 deletions(-) create mode 100644 .claude/vk-games-integration.md create mode 100644 backend/internal/postgres/migrations/00005_vk_identity.sql create mode 100644 gateway/internal/transcode/transcode_vk_test.go create mode 100644 gateway/internal/vkauth/vkauth.go create mode 100644 gateway/internal/vkauth/vkauth_test.go create mode 100644 pkg/fbs/scrabblefb/VKLoginRequest.go create mode 100644 ui/src/gen/fbs/scrabblefb/vklogin-request.ts create mode 100644 ui/src/lib/vk.test.ts create mode 100644 ui/src/lib/vk.ts diff --git a/.claude/vk-games-integration.md b/.claude/vk-games-integration.md new file mode 100644 index 0000000..b978401 --- /dev/null +++ b/.claude/vk-games-integration.md @@ -0,0 +1,157 @@ +# VK Mini App / VK Games — integration reference + +Captured research + our implementation map, so a future session does not need to re-fetch +the VK docs. Authoritative external source: (the `dev.vk.com` portal +does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM +reference repos cited at the end and verified against our own Go implementation). + +A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside +vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android). +We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/` +entry — the single-origin, path-routed model. + +## 1. Embedding model + +- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid + cert required), appending the signed launch parameters as the **URL query string**. +- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same). +- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the + configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the + clickjacking note in §Security.) +- URL must match the settings exactly (scheme, host, no stray `www`/whitespace). + +## 2. Launch parameters (URL query) + +VK appends these to the iframe `src`. The `vk_*` set is what the signature covers. + +| Param | Meaning | +| --- | --- | +| `vk_user_id` | signed-in VK user numeric id — **the identity** | +| `vk_app_id` | our registered app id | +| `vk_is_app_user` | 0/1 — user authorized/installed the app | +| `vk_are_notifications_enabled` | 0/1 | +| `vk_language` | 2-letter UI language (`ru`, `en`, …) | +| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … | +| `vk_ts` | unix seconds when VK generated the params | +| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) | +| `vk_access_token_settings` | comma-separated granted scopes (often empty) | +| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual | +| `sign` | **the signature** (see §3) — NOT part of the signed set | + +Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`. + +The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via +`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only. + +## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex + +Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference): + +1. Collect the query params whose key starts with `vk_` (exclude `sign`). +2. Sort by key (alphabetical). +3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's + reference serialization for the constrained launch-param charset). +4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»** + (protected / secure key, a.k.a. client_secret) from the app settings. +5. **base64url, no padding** (`+`→`-`, `/`→`_`, strip `=`). +6. Constant-time compare against `sign`. + +VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce +freshness — the minted server session is the short-lived credential; a replay only +re-authenticates the same `vk_user_id`. + +Verified against the official doc +(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by +independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret +`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does +NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our +`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors, +incl. the `%2C` comma case for `vk_access_token_settings`). + +## 4. VK Bridge (client SDK) + +`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use: + +- `VKWebAppInit` — **required**: tells VK the Mini App loaded (dismisses VK's loading cover). +- `VKWebAppGetUserInfo` — `{ id, first_name, last_name, photo_200, … }`; no extra scope needed. +- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server + verification — read `window.location.search` instead, which carries `sign`). +- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API). +- `VKWebAppShare` — native share dialog (deferred). +- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile). +- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme + theme changes. + +The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so +it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import +it **lazily** so the pure URL helpers stay node-test-importable. + +## 5. Test mode (to verify before moderation) + +1. App already registered (we have the App ID). +2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=`): + - Category = **Игра** (Game). + - **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing, + prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same. + - Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret). + - Add own VK id to **testers**; open in test mode. +3. Test mode = visible only to admins/testers, no payments processed. + +## 6. Auth / identity (our model) + +- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`, + auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the + display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty). +- No VK access token / VK API call needed for the launch+login MVP. + +## 7. Payments / monetization + +VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned. + +## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит») + +- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru), + a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game, + NOT "Scrabble". The repo name is internal and irrelevant to moderation. +- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data: + `vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description. +- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a + dictionary game, flag if moderation asks. +- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered. +- Moderation reviews after submission (commonly ~24–72h); rejects on violence/hate/sexual/illegal + content or IP infringement — none apply. + +## 9. Platforms + +Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge +methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation +without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client +(not reproducible in Playwright — like the iOS gesture caveats). + +## 10. Our implementation map (what to touch for VK) + +- **Wire**: `pkg/fbs/scrabble.fbs` → `VKLoginRequest{ params, browser_tz, display_name }` + (regen: `make -C pkg fbs` + `pnpm -C ui codegen`). +- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk` + (registered via `WithVKAuth(secret)` option; `DomainCode` → `invalid_vk_params`), + `internal/backendclient` `VKAuth` → `POST /api/v1/internal/sessions/vk`, + config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`. +- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform + kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration + `00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract). +- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName`), + `app.svelte.ts` `bootVK` + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, + `codec.ts` `encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`. +- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in + `docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml` + (`PROD_…` secret, deploy-main). +- **Deferred** (not in the test-mode MVP): `VKWebAppShare`, deep-links (`vk_ref`/startapp), + VK theme/viewport forcing, `VITE_VK_APP_ID` build arg, payments, account-linking a vk identity + to an existing account. + +## Sources + +- VKCOM/vk-bridge — +- VKCOM/vk-apps-launch-params (canonical signature examples) — +- kravetsone/vk-launch-params — +- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — +- VK Mini Apps API — diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 38b92fe..c180290 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -261,6 +261,9 @@ jobs: GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }} TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }} + # VK Mini App protected key (offline HMAC for the launch-param signature); empty + # leaves the VK auth path (auth.vk) disabled until the operator sets the secret. + GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} diff --git a/.gitea/workflows/prod-deploy.yaml b/.gitea/workflows/prod-deploy.yaml index e4bcfac..99602d0 100644 --- a/.gitea/workflows/prod-deploy.yaml +++ b/.gitea/workflows/prod-deploy.yaml @@ -82,6 +82,7 @@ jobs: GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} + GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }} PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} @@ -136,6 +137,7 @@ jobs: export APP_VERSION='$TAG' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' + export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET' export GATEWAY_ABUSE_BAN_ENABLED='true' EOF printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 1591f2d..cd4b5a0 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -22,12 +22,13 @@ import ( "scrabble/backend/internal/postgres/jet/backend/table" ) -// Identity kinds recognised by the backend. Email is modelled as an identity -// alongside platform identities; its confirmed flag is driven by the email -// confirm-code flow. Robot is a synthetic kind: each pooled -// robot opponent is a durable account bound to one robot identity. +// Identity kinds recognised by the backend. Telegram and VK are platform identities, +// auto-confirmed on first contact. Email is modelled as an identity alongside them; its +// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind: +// each pooled robot opponent is a durable account bound to one robot identity. const ( KindTelegram = "telegram" + KindVK = "vk" KindEmail = "email" KindRobot = "robot" ) @@ -185,6 +186,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, return acc, created, err } +// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting +// whether this call created it (first contact). On first contact only, it seeds the new +// account's preferred language from the VK languageCode (vk_language, when it maps to a +// supported language) and its display name sanitized from displayName — the name read +// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params — +// falling back to a generated placeholder when it yields no letters; an already-existing +// account is returned unchanged, so a later profile edit is never overwritten. +func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) { + // Pre-check whether the identity already exists so the caller can act on first + // contact (mirrors ProvisionTelegram); a create race only mis-reports created for + // that one call. + _, err := s.findByIdentity(ctx, KindVK, externalID) + created := errors.Is(err, ErrNotFound) + if err != nil && !created { + return Account{}, false, err + } + seed := vkSeed(languageCode, displayName) + seed.timeZone = seedZone(browserTZ) + acc, err := s.provision(ctx, KindVK, externalID, seed) + return acc, created, err +} + // provision finds the account for (kind, externalID) or creates it with seed, // collapsing a concurrent-create race on the identity unique constraint into a // re-read of the winner's account. @@ -258,6 +281,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed { return seed } +// vkSeed derives the create-time seed from VK launch fields: a supported preferred +// language from languageCode (vk_language, normally a 2-letter code) and a display name +// from displayName (sanitized to the editable format), falling back to a generated +// placeholder in the seeded language when the name yields no usable letters. Unlike +// telegramSeed there is no @username fallback — VK provides only the name. +func vkSeed(languageCode, displayName string) provisionSeed { + var seed provisionSeed + if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { + seed.preferredLanguage = lang + } + name := sanitizeDisplayName(displayName) + if name == "" { + name = placeholderDisplayName(seed.preferredLanguage) + } + seed.displayName = name + return seed +} + // GetByID loads the account identified by id, or ErrNotFound when it is absent. func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) { stmt := postgres.SELECT(table.Accounts.AllColumns). @@ -421,7 +462,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis table.Identities.Kind, table.Identities.ExternalID, table.Identities.Confirmed, - ).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram) + ).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK) if _, err := insertIdentity.ExecContext(ctx, tx); err != nil { return err } diff --git a/backend/internal/account/provision_test.go b/backend/internal/account/provision_test.go index ea63490..4535fd1 100644 --- a/backend/internal/account/provision_test.go +++ b/backend/internal/account/provision_test.go @@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) { t.Errorf("display name rune count = %d, want %d", n, maxDisplayName) } } + +// TestVKSeed covers the pure mapping from VK launch fields to the create-time account +// seed: supported-language detection from vk_language (bare and region-tagged) and the +// display name sanitized from the client-supplied name. Unlike Telegram there is no +// @username fallback — VK provides only the name. +func TestVKSeed(t *testing.T) { + cases := map[string]struct { + languageCode, displayName string + wantLang, wantName string + }{ + "ru bare": {"ru", "Иван", "ru", "Иван"}, + "en region-tagged": {"en-US", "John", "en", "John"}, + "full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"}, + "unknown language": {"uk", "Тарас", "", "Тарас"}, + "empty language": {"", "Neo", "", "Neo"}, + "trimmed": {" RU ", " Anna ", "ru", "Anna"}, + "emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := vkSeed(tc.languageCode, tc.displayName) + if got.preferredLanguage != tc.wantLang { + t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang) + } + if got.displayName != tc.wantName { + t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName) + } + }) + } +} + +// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a +// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN"). +func TestVKSeedPlaceholder(t *testing.T) { + cases := map[string]struct { + languageCode, displayName string + wantRe string + }{ + "en empty": {"en", "", `^Player-\d{5}$`}, + "ru empty": {"ru", "", `^Игрок-\d{5}$`}, + "default en": {"uk", "", `^Player-\d{5}$`}, + "name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := vkSeed(tc.languageCode, tc.displayName).displayName + if !regexp.MustCompile(tc.wantRe).MatchString(got) { + t.Errorf("displayName = %q, want match %s", got, tc.wantRe) + } + }) + } +} diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index 2f46a28..05818e2 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) { {"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"}, {"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"}, + {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"}, {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 236961f..9be7009 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -142,6 +142,11 @@ {{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} {{end}} +{{if .VKID}} +

VK

+

VK ID: {{.VKID}} · open profile

+
+{{end}}

Games

diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go index 4908ebe..a4db45b 100644 --- a/backend/internal/adminconsole/views.go +++ b/backend/internal/adminconsole/views.go @@ -156,13 +156,17 @@ type UserDetailView struct { HintBalance int // HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the // server's maxHintGrant), passed through so the policy value lives in one place. - HintGrantMax int - CreatedAt string - HasStats bool - Stats StatsRow - Identities []IdentityRow - Games []GameRow + HintGrantMax int + CreatedAt string + HasStats bool + Stats StatsRow + Identities []IdentityRow + Games []GameRow + // TelegramID and VKID are the account's platform external ids (empty when absent). + // TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK + // user id with a link to the VK profile (there is no VK messaging to drive). TelegramID string + VKID string ConnectorEnabled bool // MoveChart is the pre-rendered inline SVG of the account's per-move-number think // time (min/mean/max), empty when the account has no timed move. diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 253f7ab..9e0e1f4 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -154,6 +154,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { } } +// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's +// language, display name and time zone from the launch fields / detected offset, records +// the vk identity as confirmed (a platform identity), and never overwrites an existing +// account on a later launch. It also exercises the widened identities.kind CHECK — a +// 'vk' row must insert. +func TestProvisionVKSeedsNewAccountOnly(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + ext := "vk-" + uuid.NewString() + + acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00") + if err != nil { + t.Fatalf("provision vk: %v", err) + } + if !created { + t.Error("created = false on first contact, want true") + } + if acc.PreferredLanguage != "ru" { + t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage) + } + if acc.DisplayName != "Иван Петров" { + t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName) + } + if acc.TimeZone != "+03:00" { + t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone) + } + // A VK identity is a platform identity: confirmed on insert. + if !identityConfirmed(t, account.KindVK, ext) { + t.Error("vk identity must be confirmed") + } + + // A later launch with different fields returns the same account, unchanged. + again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00") + if err != nil { + t.Fatalf("re-provision vk: %v", err) + } + if created { + t.Error("created = true on a repeat launch, want false") + } + if again.ID != acc.ID { + t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) + } + if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" { + t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone) + } +} + // TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a // valid detected offset is stored verbatim (even "+00:00", which is deliberately // distinct from the unset "UTC" default), a guest is seeded the same way, and a diff --git a/backend/internal/postgres/migrations/00005_vk_identity.sql b/backend/internal/postgres/migrations/00005_vk_identity.sql new file mode 100644 index 0000000..3f06553 --- /dev/null +++ b/backend/internal/postgres/migrations/00005_vk_identity.sql @@ -0,0 +1,13 @@ +-- Admit the 'vk' platform identity (VK Mini App users) into the identities.kind check +-- constraint, alongside telegram/email/robot. Expand-contract: widening the allowed set +-- is backward-compatible, so a backend image rollback stays DB-safe — the older code +-- simply never writes a 'vk' row. The table shape is unchanged (only the CHECK), so the +-- generated go-jet model is not regenerated. + +-- +goose Up +ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk; +ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'vk'::text, 'email'::text, 'robot'::text]))); + +-- +goose Down +ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk; +ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text]))); diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 764ed4e..2451514 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -27,6 +27,7 @@ func (s *Server) registerRoutes() { if s.sessions != nil && s.accounts != nil { in := s.internal in.POST("/sessions/telegram", s.handleTelegramAuth) + in.POST("/sessions/vk", s.handleVKAuth) in.POST("/sessions/guest", s.handleGuestAuth) in.POST("/sessions/email/request", s.handleEmailRequest) in.POST("/sessions/email/login", s.handleEmailLogin) diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 0286a5e..2ed1902 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -362,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) { if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil { view.TelegramID = tg } + if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil { + view.VKID = vk + } if games, err := s.games.ListForAccount(ctx, id); err == nil { for _, g := range games { view.Games = append(view.Games, gameRow(g)) diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index 4d23524..23cbce6 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -65,6 +65,37 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { s.mintSession(c, acc) } +// vkAuthRequest carries the identity the gateway extracted from verified VK launch +// params. LanguageCode (vk_language) and DisplayName (read client-side via +// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new +// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC +// offset) seeds its time zone. All seeds apply on first contact only. +type vkAuthRequest struct { + ExternalID string `json:"external_id"` + LanguageCode string `json:"language_code"` + DisplayName string `json:"display_name"` + BrowserTZ string `json:"browser_tz"` +} + +// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a +// session for it, seeding a new account's language and display name from the supplied VK +// fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation +// or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the +// MVP carries no launch deep link. +func (s *Server) handleVKAuth(c *gin.Context) { + var req vkAuthRequest + if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { + abortBadRequest(c, "external_id is required") + return + } + acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ) + if err != nil { + s.abortErr(c, err) + return + } + s.mintSession(c, acc) +} + // pushTargetRequest asks for a user's out-of-app push routing data by account id. type pushTargetRequest struct { UserID string `json:"user_id"` diff --git a/deploy/.env.example b/deploy/.env.example index d33d8ee..acd067f 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -55,3 +55,9 @@ TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a vari TELEGRAM_MINIAPP_URL= # required TELEGRAM_TEST_ENV=false TELEGRAM_API_BASE_URL= + +# --- VK Mini App ------------------------------------------------------------ +# The VK app's "protected key" (client_secret): the gateway verifies the Mini App +# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call). +# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret. +GATEWAY_VK_APP_SECRET= diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index bb28b63..50df064 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,6 +1,6 @@ # Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers # every operator surface under /_gm (the backend-rendered admin console and the -# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to +# Grafana subpath); the game SPA (/app/, /telegram/, /vk/) and the Connect edge go to # the gateway; the catch-all — notably the public landing at / — goes to the # static landing container, so stray traffic never reaches the Go edge. # Mirrors ../galaxy-game's /_gm model. @@ -53,7 +53,7 @@ # The game SPA and the Connect edge are served by the gateway. Strip any # client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the # tag the honeypot block sets below (a client cannot self-tag a real request). - @gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/* + @gateway path /app /app/* /telegram /telegram/* /vk /vk/* /scrabble.edge.v1.Gateway/* handle @gateway { reverse_proxy gateway:8081 { header_up -X-Scrabble-Honeypot diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b0b8692..34893cb 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -147,6 +147,10 @@ services: GATEWAY_BACKEND_GRPC_ADDR: backend:9090 # Telegram auth validates against the home validator (plaintext, internal). GATEWAY_VALIDATOR_ADDR: validator:9091 + # VK Mini App auth verifies the launch-parameter signature in-process under the VK + # app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables + # the VK auth path (auth.vk is then unregistered). + GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-} # The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay # reaches the gateway at :9092 (plaintext, internal). In the test contour both # listeners stay on the internal network (the bot shares the VPN netns); in prod diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2c0a3f4..ad04ea9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -149,11 +149,18 @@ arrive from a platform rather than completing a mandatory registration). - The gateway validates the originating credential **once** — Telegram `initData` (delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token — - the HMAC secret — so it never reaches the gateway), an email-code login, or a guest + the HMAC secret — so it never reaches the gateway), a **VK Mini App** launch (verified + **in-process** by `gateway/internal/vkauth`: HMAC-SHA256 over the signed `vk_*` params + under the VK app's protected key `GATEWAY_VK_APP_SECRET`, base64url — a pure offline check, + as VK signing needs no API round-trip, so no side-service), an email-code login, or a guest bootstrap — then mints a **thin opaque server session token** (`session_id`). First - Telegram contact seeds the new account's language (from the launch `language_code`) - and display name (§4). The validator runs on the main host and never reaches the Bot - API, so login does not depend on Telegram or the remote bot being up (§10, §12). + Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's + `vk_language`) and display name (§4; VK omits the name from the signed params, so the client + reads it via `VKWebAppGetUserInfo` as an unsigned, cosmetic seed). The validator runs on the + main host and never reaches the Bot API, so login does not depend on Telegram or the remote + bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram's + `auth_date`), so freshness is not enforced — the minted session is the short-lived credential, + and a replay only re-authenticates the same `vk_user_id`. - **Single bot.** The platform side-service runs **one bot** (one token + one optional game channel), split into a home **validator** and a remote **bot** that share the token. `ValidateInitData` (the validator) validates `initData` against that single @@ -1046,10 +1053,11 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app Single public origin, path-routed. The Vite build has two entries: a lightweight **landing page** and the game **SPA**. The gateway **embeds** the SPA build (`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at -`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data +`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data — no `initData` — the client renders a compact, shareable launch-diagnostic screen instead -of redirecting away); a stray hit on the gateway's `/` -308-redirects to `/app/`. The **landing** ships in its own static container: the +of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch +parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the +gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by static file serving and never reaches the Go edge. Hash-named `/assets/*` are served @@ -1058,7 +1066,7 @@ static file serving and never reaches the Go edge. Hash-named `/assets/*` are se in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered -**admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the +**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the catch-all — notably the landing at `/` — goes to the landing container. The **Telegram validator** runs as a separate container with **no public ingress**, answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 4b242b0..f0651dc 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -22,7 +22,7 @@ costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. A public **landing page** at the site root introduces the game, switches language and theme, and links to the matching per-language Telegram channel; the game itself runs at -`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral +`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral (it follows the system scheme, not the saved preference); its language choice is saved. ### Identity & sessions @@ -35,6 +35,10 @@ the device safe-area, and — on first contact — seeds the new account's inter language from the Telegram client. If a launch cannot reach the backend (for example during a deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a **Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram. +A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters +(verified by the gateway), and on first contact seeds the new account's interface language from +`vk_language` and its display name from the VK profile (read on the client, since VK does not put +the name in the signed launch). The same quiet-retry "couldn't load" screen applies inside VK. Telegram runs a **single bot**: every player uses the same bot, and all of its chat and out-of-app notifications are written in the player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index f06af8c..75a8733 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -23,7 +23,7 @@ top-1 подсказку, безлимитную проверку слова с и ограничивает частоту повторов. Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам -`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из +`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из системной настройки, а не из сохранённой), выбор языка сохраняется. ### Личность и сессии @@ -37,7 +37,11 @@ Mini App** авторизует по подписанным `initData` плат языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри -Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним +Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным +launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса +нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте, +так как VK не кладёт имя в подписанный запуск). Тот же экран тихого повтора «не удалось +загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный **промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, diff --git a/gateway/README.md b/gateway/README.md index e317ec8..4327888 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -7,7 +7,7 @@ thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the backend over REST/JSON, and bridges the backend's gRPC push stream to each client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked in by the gateway image's node stage) and serves a **landing page** at `/` and the game -**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model. +**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model. Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run; in the deployed contour the front caddy owns `/_gm` (see @@ -29,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams) internal/transcode/ # FlatBuffers<->REST bridge + message_type registry internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge) internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim) -internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ +internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/ ``` The FlatBuffers payloads and the backend push proto are the shared wire @@ -54,7 +54,14 @@ them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). Wh `GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR` is unset the bot channel (out-of-app push + admin relay) is disabled. -The message-type catalog: `auth.telegram`, `auth.guest`, +`auth.vk` validates a VK Mini App launch **in-process** (`internal/vkauth`): it verifies the +signed `vk_*` launch parameters against the VK app's protected key (`GATEWAY_VK_APP_SECRET`) — +HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API +round-trip, then forwards the trusted `vk_user_id` (and the client-read display name, which VK omits +from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled +(`auth.vk` is unregistered). + +The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops; live events @@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These | `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call | | `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` | | `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) | +| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) | | `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) | | `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay | | `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) | diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index e06f443..b108877 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -190,7 +190,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)") } - registry := transcode.NewRegistry(backend, validator) + registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret)) edge := connectsrv.NewServer(connectsrv.Deps{ Registry: registry, Sessions: sessions, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index dbc166e..84ea8cd 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -201,6 +201,23 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use return out, err } +// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new +// account's preferred language from languageCode (the vk_language hint), its display +// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the +// name from the signed launch params) and its time zone from browserTz (the client's +// detected "±HH:MM" UTC offset). All seeds apply on first contact only. +func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) { + var out SessionResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "", + map[string]string{ + "external_id": externalID, + "language_code": languageCode, + "display_name": displayName, + "browser_tz": browserTz, + }, &out) + return out, err +} + // PushTargetResp is a recipient's out-of-app push routing data: their Telegram // external_id (empty when they have no Telegram identity), preferred language, and // whether they confined notifications to the in-app stream. diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 9cb1fee..98ea120 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -32,6 +32,10 @@ type Config struct { // plaintext, internal). The gateway calls it to validate Mini App initData and // Login Widget data. Empty disables the telegram auth path. ValidatorAddr string + // VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the + // VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API + // round-trip). Empty disables the VK auth path (auth.vk is then unregistered). + VKAppSecret string // BotLink configures the reverse mTLS channel to the remote Telegram bot. An // empty BotLink.Addr disables the bot channel (out-of-app push and admin relay). BotLink BotLinkConfig @@ -169,6 +173,7 @@ func Load() (Config, error) { AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), + VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"), SessionCacheMax: defaultSessionCacheMax, RateLimit: DefaultRateLimit(), Abuse: DefaultAbuse(), diff --git a/gateway/internal/connectsrv/server.go b/gateway/internal/connectsrv/server.go index f0665da..1b38693 100644 --- a/gateway/internal/connectsrv/server.go +++ b/gateway/internal/connectsrv/server.go @@ -183,14 +183,15 @@ func (s *Server) HTTPHandler() http.Handler { // does not serve the app shell at the operator path. mux.Handle("/_gm/", http.NotFoundHandler()) } - // The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram - // Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below - // the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and - // each mount falls back to the app shell (index.html) for the hash router. The - // public landing lives in its own static container behind the contour caddy, - // so the catch-all redirects a stray root hit to the app shell — which - // keeps a local no-caddy run usable. + // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini + // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md + // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps + // priority, and each mount falls back to the app shell (index.html) for the hash + // router. The public landing lives in its own static container behind the contour + // caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a + // local no-caddy run usable. mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html")) + mux.Handle("/vk/", webui.Handler("/vk/", "index.html")) mux.Handle("/app/", webui.Handler("/app/", "index.html")) mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect)) // abuseGuard is the outermost wrap (right under h2c) so a banned IP or a diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index b08acbf..26c775f 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -13,12 +13,14 @@ import ( "scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/connector" + "scrabble/gateway/internal/vkauth" fb "scrabble/pkg/fbs/scrabblefb" ) // Message types in the vertical slice. const ( MsgAuthTelegram = "auth.telegram" + MsgAuthVK = "auth.vk" MsgAuthGuest = "auth.guest" MsgAuthEmailReq = "auth.email.request" MsgAuthEmailLogin = "auth.email.login" @@ -89,8 +91,9 @@ type TelegramValidator interface { // NewRegistry builds the slice's message-type catalog over the backend client. // The Telegram auth op is registered only when a validator is supplied (the -// connector is configured); otherwise auth.telegram is simply unknown. -func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry { +// connector is configured); otherwise auth.telegram is simply unknown. Optional ops +// (e.g. WithVKAuth) are applied last from opts. +func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry { r := &Registry{ops: make(map[string]Op)} if tg != nil { r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)} @@ -126,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true} registerSocialOps(r, backend) registerLinkOps(r, backend, tg) + for _, opt := range opts { + opt(r, backend) + } return r } +// Option configures an optional registry operation at construction. It is kept out of +// NewRegistry's positional signature so existing call sites stay unaffected. +type Option func(r *Registry, backend *backendclient.Client) + +// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params +// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so +// the op is simply unknown wherever VK is not configured. +func WithVKAuth(secret string) Option { + return func(r *Registry, backend *backendclient.Client) { + if secret != "" { + r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)} + } + } +} + // Lookup returns the operation for messageType, and whether it is registered. func (r *Registry) Lookup(messageType string) (Op, bool) { op, ok := r.ops[messageType] @@ -146,6 +167,9 @@ func DomainCode(err error) (string, bool) { if errors.Is(err, connector.ErrInvalidInitData) { return "invalid_init_data", true } + if errors.Is(err, vkauth.ErrInvalid) { + return "invalid_vk_params", true + } if errors.Is(err, connector.ErrInvalidLoginWidget) { return "invalid_login_widget", true } @@ -175,6 +199,25 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha } } +// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_* +// params under the app secret) and provisions/finds the bound account. Unlike Telegram, +// VK omits the user's name from the signed params, so the client-supplied display_name +// rides the wire as a cosmetic seed for a brand-new account. +func authVKHandler(backend *backendclient.Client, secret string) Handler { + return func(ctx context.Context, req Request) ([]byte, error) { + in := fb.GetRootAsVKLoginRequest(req.Payload, 0) + user, err := vkauth.Verify(string(in.Params()), secret) + if err != nil { + return nil, err + } + sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz())) + if err != nil { + return nil, err + } + return encodeSession(sess), nil + } +} + func authGuestHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { // The guest bootstrap historically carried no payload; the detected zone is diff --git a/gateway/internal/transcode/transcode_vk_test.go b/gateway/internal/transcode/transcode_vk_test.go new file mode 100644 index 0000000..f6ede74 --- /dev/null +++ b/gateway/internal/transcode/transcode_vk_test.go @@ -0,0 +1,116 @@ +package transcode_test + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +const ( + vkTestSecret = "wv527nm6mvf3rgomfhd6" + // vkGoldenSign is the base64url HMAC-SHA256 of the unsigned params below under + // vkTestSecret, computed independently (a Python reference) — so a green test + // exercises VK's real algorithm end to end, not a self-consistent fake. + vkGoldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA" +) + +// vkParams is the canonical VK launch-parameter set (without the sign) that +// vkGoldenSign covers. +func vkParams() url.Values { + return url.Values{ + "vk_access_token_settings": {""}, + "vk_app_id": {"6736218"}, + "vk_are_notifications_enabled": {"0"}, + "vk_is_app_user": {"1"}, + "vk_is_favorite": {"0"}, + "vk_language": {"ru"}, + "vk_platform": {"android"}, + "vk_ref": {"other"}, + "vk_ts": {"1546961916"}, + "vk_user_id": {"494075"}, + } +} + +func vkLoginPayload(params, browserTz, displayName string) []byte { + b := flatbuffers.NewBuilder(0) + p := b.CreateString(params) + tz := b.CreateString(browserTz) + dn := b.CreateString(displayName) + fb.VKLoginRequestStart(b) + fb.VKLoginRequestAddParams(b, p) + fb.VKLoginRequestAddBrowserTz(b, tz) + fb.VKLoginRequestAddDisplayName(b, dn) + b.Finish(fb.VKLoginRequestEnd(b)) + return b.FinishedBytes() +} + +func TestVKAuthForwardsSeedFields(t *testing.T) { + var gotBody map[string]string + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/internal/sessions/vk" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _, _ = w.Write([]byte(`{"token":"tok-vk","user_id":"u-vk","is_guest":false,"display_name":"Иван"}`)) + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret)) + op, ok := reg.Lookup(transcode.MsgAuthVK) + if !ok { + t.Fatal("auth.vk not registered") + } + + signed := vkParams() + signed.Set("sign", vkGoldenSign) + payload, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(signed.Encode(), "+03:00", "Иван Петров")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + sess := fb.GetRootAsSession(payload, 0) + if string(sess.Token()) != "tok-vk" || string(sess.UserId()) != "u-vk" { + t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId()) + } + // The verified vk_user_id and vk_language plus the client-supplied display name are + // forwarded so the backend can seed a brand-new account. + if gotBody["external_id"] != "494075" || gotBody["language_code"] != "ru" || gotBody["display_name"] != "Иван Петров" { + t.Errorf("forwarded body = %+v, want external_id=494075 language_code=ru display_name=Иван Петров", gotBody) + } +} + +// TestVKAuthInvalidSign confirms a bad signature is a domain failure (invalid_vk_params) +// and the backend is never called. +func TestVKAuthInvalidSign(t *testing.T) { + backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) { + t.Error("backend must not be called when the sign is invalid") + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret)) + op, _ := reg.Lookup(transcode.MsgAuthVK) + + tampered := vkParams() + tampered.Set("sign", "deadbeef") + _, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(tampered.Encode(), "", "")}) + if code, ok := transcode.DomainCode(err); !ok || code != "invalid_vk_params" { + t.Errorf("DomainCode = (%q, %v), want (invalid_vk_params, true)", code, ok) + } +} + +// TestVKAuthDisabledWithoutSecret confirms a blank VK app secret leaves auth.vk +// unregistered. +func TestVKAuthDisabledWithoutSecret(t *testing.T) { + backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {}) + defer cleanup() + reg := transcode.NewRegistry(backend, nil) + if _, ok := reg.Lookup(transcode.MsgAuthVK); ok { + t.Error("auth.vk should be unregistered without a VK app secret") + } +} diff --git a/gateway/internal/vkauth/vkauth.go b/gateway/internal/vkauth/vkauth.go new file mode 100644 index 0000000..70e602f --- /dev/null +++ b/gateway/internal/vkauth/vkauth.go @@ -0,0 +1,72 @@ +// Package vkauth verifies VK Mini App launch parameters in-process. VK signs the +// launch query string with the app's protected ("secure") key; the gateway holds that +// secret (GATEWAY_VK_APP_SECRET) and validates the `sign` itself rather than calling a +// side-service, because the check is a pure offline HMAC with no VK API round-trip +// (unlike the Telegram validator, which lives in a separate process to isolate the bot +// token). See docs/ARCHITECTURE.md §12. +package vkauth + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "net/url" + "strings" +) + +// ErrInvalid is returned when launch params fail signature verification, are +// malformed, carry no signed vk_* parameters, or lack the vk_user_id. +var ErrInvalid = errors.New("vkauth: invalid vk launch params") + +// Identity is the user extracted from verified VK launch params. ExternalID is the +// vk_user_id used as the identities external_id; Language is the vk_language hint that +// seeds a brand-new account's preferred language. +type Identity struct { + ExternalID string + Language string +} + +// Verify checks the `sign` of a VK Mini App launch query string against secret and +// returns the launching user's identity. Per VK's documented algorithm the signature +// is HMAC-SHA256 over the vk_*-prefixed parameters — sorted by key and serialized as a +// URL-encoded query string (url.Values.Encode mirrors VK's reference serialization for +// the constrained launch-parameter charset) — under the app secret, then base64url +// without padding; the comparison is constant-time. +// +// VK launch params carry no built-in expiry (unlike Telegram's auth_date), so freshness +// is deliberately not enforced here: the gateway mints its own short-lived session, and +// a replay only re-authenticates the same vk_user_id. +func Verify(params, secret string) (Identity, error) { + values, err := url.ParseQuery(params) + if err != nil { + return Identity{}, ErrInvalid + } + sign := values.Get("sign") + if sign == "" { + return Identity{}, ErrInvalid + } + // Only vk_*-prefixed parameters are signed; any other query parameter the client + // appended is outside the signature and must be excluded from the check. + signed := url.Values{} + for k, v := range values { + if strings.HasPrefix(k, "vk_") { + signed[k] = v + } + } + if len(signed) == 0 { + return Identity{}, ErrInvalid + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signed.Encode())) + want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + if subtle.ConstantTimeCompare([]byte(want), []byte(sign)) != 1 { + return Identity{}, ErrInvalid + } + externalID := values.Get("vk_user_id") + if externalID == "" { + return Identity{}, ErrInvalid + } + return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil +} diff --git a/gateway/internal/vkauth/vkauth_test.go b/gateway/internal/vkauth/vkauth_test.go new file mode 100644 index 0000000..9986081 --- /dev/null +++ b/gateway/internal/vkauth/vkauth_test.go @@ -0,0 +1,111 @@ +package vkauth_test + +import ( + "errors" + "net/url" + "testing" + + "scrabble/gateway/internal/vkauth" +) + +const ( + testSecret = "wv527nm6mvf3rgomfhd6" + // goldenSign is HMAC-SHA256(sorted vk_* query, testSecret) base64url without + // padding, computed independently from a Python reference over goldenParams — so a + // green test proves Verify matches VK's documented algorithm, not merely itself. + goldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA" +) + +// goldenParams is the canonical VK launch-parameter set the golden signature covers +// (a representative real launch, including the empty vk_access_token_settings). +func goldenParams() url.Values { + return url.Values{ + "vk_access_token_settings": {""}, + "vk_app_id": {"6736218"}, + "vk_are_notifications_enabled": {"0"}, + "vk_is_app_user": {"1"}, + "vk_is_favorite": {"0"}, + "vk_language": {"ru"}, + "vk_platform": {"android"}, + "vk_ref": {"other"}, + "vk_ts": {"1546961916"}, + "vk_user_id": {"494075"}, + } +} + +func signedParams() url.Values { + p := goldenParams() + p.Set("sign", goldenSign) + return p +} + +func TestVerifyValid(t *testing.T) { + id, err := vkauth.Verify(signedParams().Encode(), testSecret) + if err != nil { + t.Fatalf("Verify: unexpected error %v", err) + } + if id.ExternalID != "494075" || id.Language != "ru" { + t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id) + } +} + +// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK +// value: vk_access_token_settings can carry a comma (e.g. "email,phone"), which VK's +// signer and our url.Values.Encode both escape to %2C. The golden sign was computed +// independently (Node crypto) over these params under testSecret — so a green test +// proves Go's encoding matches VK's for that character. +func TestVerifyCommaValue(t *testing.T) { + p := url.Values{ + "vk_access_token_settings": {"email,phone"}, + "vk_app_id": {"6736218"}, + "vk_language": {"ru"}, + "vk_platform": {"mobile_web"}, + "vk_ts": {"1546961916"}, + "vk_user_id": {"494075"}, + "sign": {"6g9FKCAfHfT-fBUdBAcJ5QK1xUm-vKMvGZrQ63aPgnQ"}, + } + id, err := vkauth.Verify(p.Encode(), testSecret) + if err != nil { + t.Fatalf("Verify: unexpected error %v", err) + } + if id.ExternalID != "494075" { + t.Fatalf("ExternalID = %q, want 494075", id.ExternalID) + } +} + +// TestVerifyIgnoresForeignParams asserts a non-vk_ query parameter the client may +// append (e.g. a tracking tag) is outside the signed set and does not break the check. +func TestVerifyIgnoresForeignParams(t *testing.T) { + id, err := vkauth.Verify(signedParams().Encode()+"&utm_source=catalog", testSecret) + if err != nil { + t.Fatalf("Verify: unexpected error %v", err) + } + if id.ExternalID != "494075" { + t.Fatalf("ExternalID = %q, want 494075", id.ExternalID) + } +} + +func TestVerifyRejects(t *testing.T) { + tests := []struct { + name string + raw string + secret string + }{ + {name: "tampered param", secret: testSecret, raw: func() string { + p := signedParams() + p.Set("vk_user_id", "1") // user id changed; the golden sign no longer matches + return p.Encode() + }()}, + {name: "wrong secret", secret: "not-the-secret", raw: signedParams().Encode()}, + {name: "missing sign", secret: testSecret, raw: goldenParams().Encode()}, + {name: "no vk params", secret: testSecret, raw: url.Values{"sign": {goldenSign}, "foo": {"bar"}}.Encode()}, + {name: "malformed query", secret: testSecret, raw: "%zz=bad"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := vkauth.Verify(tt.raw, tt.secret); !errors.Is(err, vkauth.ErrInvalid) { + t.Fatalf("Verify error = %v, want ErrInvalid", err) + } + }) + } +} diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 74f25dc..a23da6a 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -108,6 +108,20 @@ table TelegramLoginRequest { browser_tz:string; } +// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the +// signed vk_* launch parameters plus the sign, which the gateway verifies in-process +// (HMAC-SHA256 over the sorted vk_* params under the app secret, base64url) before +// forwarding the extracted vk_user_id to the backend. display_name is the player's +// name read client-side via VKWebAppGetUserInfo — VK omits it from the signed params, +// so it is an untrusted, cosmetic seed for a brand-new account's display name. +// browser_tz is the client's detected UTC offset ("±HH:MM"), seeded into a brand-new +// account's time zone (see TelegramLoginRequest.browser_tz; first contact only). +table VKLoginRequest { + params:string; + browser_tz:string; + display_name:string; +} + // GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional // preferred-language hint; browser_tz is the detected UTC offset seeded into the // guest account's time zone (see TelegramLoginRequest.browser_tz). diff --git a/pkg/fbs/scrabblefb/VKLoginRequest.go b/pkg/fbs/scrabblefb/VKLoginRequest.go new file mode 100644 index 0000000..b13ad8a --- /dev/null +++ b/pkg/fbs/scrabblefb/VKLoginRequest.go @@ -0,0 +1,82 @@ +// Code generated by the FlatBuffers compiler. DO NOT EDIT. + +package scrabblefb + +import ( + flatbuffers "github.com/google/flatbuffers/go" +) + +type VKLoginRequest struct { + _tab flatbuffers.Table +} + +func GetRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset:]) + x := &VKLoginRequest{} + x.Init(buf, n+offset) + return x +} + +func FinishVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.Finish(offset) +} + +func GetSizePrefixedRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest { + n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:]) + x := &VKLoginRequest{} + x.Init(buf, n+offset+flatbuffers.SizeUint32) + return x +} + +func FinishSizePrefixedVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) { + builder.FinishSizePrefixed(offset) +} + +func (rcv *VKLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) { + rcv._tab.Bytes = buf + rcv._tab.Pos = i +} + +func (rcv *VKLoginRequest) Table() flatbuffers.Table { + return rcv._tab +} + +func (rcv *VKLoginRequest) Params() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(4)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *VKLoginRequest) BrowserTz() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(6)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func (rcv *VKLoginRequest) DisplayName() []byte { + o := flatbuffers.UOffsetT(rcv._tab.Offset(8)) + if o != 0 { + return rcv._tab.ByteVector(o + rcv._tab.Pos) + } + return nil +} + +func VKLoginRequestStart(builder *flatbuffers.Builder) { + builder.StartObject(3) +} +func VKLoginRequestAddParams(builder *flatbuffers.Builder, params flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(params), 0) +} +func VKLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0) +} +func VKLoginRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { + builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(displayName), 0) +} +func VKLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { + return builder.EndObject() +} diff --git a/ui/package.json b/ui/package.json index a00f5cd..11fa26e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -19,6 +19,7 @@ "@bufbuild/protobuf": "^2.12.0", "@connectrpc/connect": "^2.1.0", "@connectrpc/connect-web": "^2.1.0", + "@vkontakte/vk-bridge": "^3.0.2", "flatbuffers": "^25.9.23" }, "devDependencies": { diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 9532542..5f813f2 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@connectrpc/connect-web': specifier: ^2.1.0 version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0)) + '@vkontakte/vk-bridge': + specifier: ^3.0.2 + version: 3.0.2 flatbuffers: specifier: ^25.9.23 version: 25.9.23 @@ -413,6 +416,9 @@ packages: svelte: ^5.0.0 vite: ^6.0.0 + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -462,6 +468,9 @@ packages: '@vitest/utils@3.2.6': resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} + '@vkontakte/vk-bridge@3.0.2': + resolution: {integrity: sha512-MTp+nl0/jH4Sa2TXDyxNfy9VkhOhRQR1oQ4yhvthVDA4aWUa26npns7bRgfTuR3xDVGFiqW7zAwLnwcv3mL+dA==} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -689,6 +698,9 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typescript@5.4.5: resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} engines: {node: '>=14.17'} @@ -1022,6 +1034,10 @@ snapshots: transitivePeerDependencies: - supports-color + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1086,6 +1102,10 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@vkontakte/vk-bridge@3.0.2': + dependencies: + '@swc/helpers': 0.5.23 + acorn@8.16.0: {} aria-query@5.3.1: {} @@ -1318,6 +1338,8 @@ snapshots: tinyspy@4.0.4: {} + tslib@2.8.1: {} + typescript@5.4.5: {} typescript@5.9.3: {} diff --git a/ui/src/gen/fbs/scrabblefb.ts b/ui/src/gen/fbs/scrabblefb.ts index 8f70548..d846ba5 100644 --- a/ui/src/gen/fbs/scrabblefb.ts +++ b/ui/src/gen/fbs/scrabblefb.ts @@ -71,5 +71,6 @@ export { TargetRequest } from './scrabblefb/target-request.js'; export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js'; export { TileRecord } from './scrabblefb/tile-record.js'; export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js'; +export { VKLoginRequest } from './scrabblefb/vklogin-request.js'; export { WordCheckResult } from './scrabblefb/word-check-result.js'; export { YourTurnEvent } from './scrabblefb/your-turn-event.js'; diff --git a/ui/src/gen/fbs/scrabblefb/vklogin-request.ts b/ui/src/gen/fbs/scrabblefb/vklogin-request.ts new file mode 100644 index 0000000..cf13da1 --- /dev/null +++ b/ui/src/gen/fbs/scrabblefb/vklogin-request.ts @@ -0,0 +1,72 @@ +// automatically generated by the FlatBuffers compiler, do not modify + +import * as flatbuffers from 'flatbuffers'; + +export class VKLoginRequest { + bb: flatbuffers.ByteBuffer|null = null; + bb_pos = 0; + __init(i:number, bb:flatbuffers.ByteBuffer):VKLoginRequest { + this.bb_pos = i; + this.bb = bb; + return this; +} + +static getRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest { + return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +static getSizePrefixedRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest { + bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH); + return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb); +} + +params():string|null +params(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +params(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 4); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +browserTz():string|null +browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +browserTz(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 6); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +displayName():string|null +displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null +displayName(optionalEncoding?:any):string|Uint8Array|null { + const offset = this.bb!.__offset(this.bb_pos, 8); + return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; +} + +static startVKLoginRequest(builder:flatbuffers.Builder) { + builder.startObject(3); +} + +static addParams(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset) { + builder.addFieldOffset(0, paramsOffset, 0); +} + +static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) { + builder.addFieldOffset(1, browserTzOffset, 0); +} + +static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { + builder.addFieldOffset(2, displayNameOffset, 0); +} + +static endVKLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset { + const offset = builder.endObject(); + return offset; +} + +static createVKLoginRequest(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset { + VKLoginRequest.startVKLoginRequest(builder); + VKLoginRequest.addParams(builder, paramsOffset); + VKLoginRequest.addBrowserTz(builder, browserTzOffset); + VKLoginRequest.addDisplayName(builder, displayNameOffset); + return VKLoginRequest.endVKLoginRequest(builder); +} +} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index cd8902d..6573de8 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -32,6 +32,7 @@ import { telegramCloudGet, telegramCloudSet, } from './telegram'; +import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName } from './vk'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; @@ -664,6 +665,17 @@ export async function bootstrap(): Promise { return; } + // VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then + // authenticate from the signed launch parameters in the URL — the display name comes from + // VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK + // (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow. + if (onVKPath() && insideVK()) { + await vkInit(); + await bootVK(); + app.ready = true; + return; + } + const saved = await loadSession(); if (saved) { await adoptSession(saved); @@ -674,10 +686,10 @@ export async function bootstrap(): Promise { app.ready = true; } -// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall -// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a -// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the -// same path (retryTelegramBoot). +// Inside a Mini App the only identity is the platform session (Telegram or VK), so a failed launch +// must never fall back to the web login screen. A transient backend outage (a deploy rolling over) +// is retried a few times in silence; only then does the boot-error screen surface, from which Retry +// re-runs the same path (retryMiniAppBoot). const TELEGRAM_BOOT_RETRIES = 2; const TELEGRAM_BOOT_RETRY_MS = 1200; @@ -714,14 +726,48 @@ async function bootTelegram(launch: TelegramLaunch): Promise { } /** - * retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It - * clears the error and shows the loading state again, then runs the same retrying boot; on success - * the app renders normally, otherwise the boot-error screen returns. + * bootVK authenticates a VK Mini App launch from the signed launch parameters in the URL, seeding a + * brand-new account's display name from VKWebAppGetUserInfo. Like bootTelegram it retries a few + * times on a transient failure before raising the boot-error screen, and a blocked account is + * terminal. This MVP carries no VK deep-link routing. */ -export async function retryTelegramBoot(): Promise { +async function bootVK(): Promise { + const params = vkLaunchParams(); + const displayName = await vkUserName(); + for (let attempt = 0; ; attempt++) { + try { + await adoptSession(await gateway.authVK(params, displayName)); + app.bootError = false; + return; + } catch (err) { + if (err instanceof GatewayError && err.code === 'account_blocked') { + await enterBlocked(); + return; + } + if (attempt >= TELEGRAM_BOOT_RETRIES) { + app.bootError = true; + return; + } + await delay(TELEGRAM_BOOT_RETRY_MS); + } + } +} + +/** + * retryMiniAppBoot re-attempts a Mini App launch from the boot-error screen's Retry button — the VK + * boot on the /vk/ entry, the Telegram boot otherwise. It clears the error and shows the loading + * state again, then runs the same retrying boot; on success the app renders normally, otherwise the + * boot-error screen returns. + */ +export async function retryMiniAppBoot(): Promise { app.bootError = false; app.ready = false; - await bootTelegram(telegramLaunch()); + if (onVKPath()) { + await vkInit(); + await bootVK(); + } else { + await bootTelegram(telegramLaunch()); + } app.ready = true; } diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 4b45b90..27a56db 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -58,6 +58,10 @@ export type Unsubscribe = () => void; export interface GatewayClient { // --- auth (unauthenticated) --- authTelegram(initData: string): Promise; + /** Authenticate a VK Mini App launch: params is the signed vk_* launch query string (the gateway + * verifies its sign); displayName is the client-read VKWebAppGetUserInfo name (an unsigned, + * cosmetic seed for a brand-new account). */ + authVK(params: string, displayName: string): Promise; authGuest(locale?: string): Promise; authEmailRequest(email: string): Promise; authEmailLogin(email: string, code: string): Promise; diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 98c299c..9e84fb2 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -30,6 +30,7 @@ import { encodeTarget, encodeTelegramLogin, encodeUpdateProfile, + encodeVKLogin, } from './codec'; describe('codec', () => { @@ -94,6 +95,13 @@ describe('codec', () => { ); expect(email.email()).toBe('a@example.com'); expect(email.browserTz()).toBe('+00:00'); + + const vk = fb.VKLoginRequest.getRootAsVKLoginRequest( + new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')), + ); + expect(vk.params()).toBe('vk_user_id=494075&vk_ts=1&sign=abc'); + expect(vk.browserTz()).toBe('+03:00'); + expect(vk.displayName()).toBe('Иван Петров'); }); it('round-trips a feedback submit and decodes state + unread', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index abc0181..0c4f472 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -189,6 +189,18 @@ export function encodeTelegramLogin(initData: string, browserTz: string): Uint8A return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b)); } +export function encodeVKLogin(params: string, browserTz: string, displayName: string): Uint8Array { + const b = new Builder(512); + const p = b.createString(params); + const tz = b.createString(browserTz); + const dn = b.createString(displayName); + fb.VKLoginRequest.startVKLoginRequest(b); + fb.VKLoginRequest.addParams(b, p); + fb.VKLoginRequest.addBrowserTz(b, tz); + fb.VKLoginRequest.addDisplayName(b, dn); + return finish(b, fb.VKLoginRequest.endVKLoginRequest(b)); +} + export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array { const b = new Builder(64); const l = b.createString(locale); diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index 61a5224..1bf2644 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -142,6 +142,9 @@ export class MockGateway implements GatewayClient { if (initData.includes('bootfail')) throw new GatewayError('unavailable'); return { ...SESSION, isGuest: false }; } + async authVK(): Promise { + return { ...SESSION, isGuest: false }; + } async authGuest(): Promise { return { ...SESSION }; } diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 9360769..1dfa4e4 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -65,6 +65,9 @@ export function createTransport(baseUrl: string): GatewayClient { async authTelegram(initData) { return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); }, + async authVK(params, displayName) { + return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName))); + }, async authGuest(locale) { return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset()))); }, diff --git a/ui/src/lib/vk.test.ts b/ui/src/lib/vk.test.ts new file mode 100644 index 0000000..77e317f --- /dev/null +++ b/ui/src/lib/vk.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { insideVK, onVKPath, vkLaunchParams } from './vk'; + +describe('vk launch detection', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('is not inside VK and not on the VK path without a location (node / SSR)', () => { + expect(onVKPath()).toBe(false); + expect(insideVK()).toBe(false); + expect(vkLaunchParams()).toBe(''); + }); + + it('detects the dedicated /vk/ entry path', () => { + vi.stubGlobal('location', { pathname: '/vk/', search: '' }); + expect(onVKPath()).toBe(true); + vi.stubGlobal('location', { pathname: '/app/', search: '' }); + expect(onVKPath()).toBe(false); + }); + + it('returns the signed launch query only when a sign is present', () => { + vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&vk_ts=2&sign=abc' }); + expect(vkLaunchParams()).toBe('vk_user_id=1&vk_ts=2&sign=abc'); + expect(insideVK()).toBe(true); + }); + + it('treats a URL carrying no sign as not a VK launch', () => { + vi.stubGlobal('location', { pathname: '/vk/', search: '?utm_source=catalog' }); + expect(vkLaunchParams()).toBe(''); + expect(insideVK()).toBe(false); + }); +}); diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts new file mode 100644 index 0000000..7bf2da6 --- /dev/null +++ b/ui/src/lib/vk.ts @@ -0,0 +1,66 @@ +// VK Mini App SDK access via @vkontakte/vk-bridge. The bridge is imported lazily inside the +// functions that need it — not at module top level — because the SDK reads browser globals on +// import: the lazy import keeps the pure URL helpers below importable in the node test environment, +// and code-splits the bridge into a chunk loaded only on the /vk/ entry. This wraps the subset the +// app uses: launch detection, the signed launch parameters (for auth.vk) and the user's display name +// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to +// call outside VK. + +async function bridge() { + return (await import('@vkontakte/vk-bridge')).default; +} + +/** + * onVKPath reports whether the app is served under the dedicated VK entry path (/vk/). + */ +export function onVKPath(): boolean { + if (typeof location === 'undefined') return false; + return location.pathname.startsWith('/vk/'); +} + +/** + * vkLaunchParams returns the raw signed VK launch query string (the vk_* parameters plus sign) from + * the page URL — the exact form the gateway verifies — or '' when the URL carries no signed launch + * (an ordinary browser tab, or the /vk/ path opened directly). + */ +export function vkLaunchParams(): string { + if (typeof location === 'undefined') return ''; + const query = location.search.replace(/^\?/, ''); + return new URLSearchParams(query).has('sign') ? query : ''; +} + +/** + * insideVK reports whether the app launched as a VK Mini App — the URL carries signed launch + * parameters (an ordinary browser tab has none). + */ +export function insideVK(): boolean { + return vkLaunchParams() !== ''; +} + +/** + * vkInit signals to the VK client that the Mini App has loaded (VKWebAppInit), dismissing VK's own + * loading cover. Best-effort: it resolves even if the bridge is unavailable (outside VK), so the + * caller can await it unconditionally. + */ +export async function vkInit(): Promise { + try { + await (await bridge()).send('VKWebAppInit', {}); + } catch { + // Outside VK there is no client to receive it; the launch continues regardless. + } +} + +/** + * vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it + * from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back + * to a generated placeholder. The value is a cosmetic seed only — being unsigned, the gateway never + * trusts it for identity. + */ +export async function vkUserName(): Promise { + try { + const u = await (await bridge()).send('VKWebAppGetUserInfo', {}); + return [u.first_name, u.last_name].filter(Boolean).join(' ').trim(); + } catch { + return ''; + } +} diff --git a/ui/src/screens/BootError.svelte b/ui/src/screens/BootError.svelte index 92ddee5..e1d1abd 100644 --- a/ui/src/screens/BootError.svelte +++ b/ui/src/screens/BootError.svelte @@ -1,8 +1,9 @@
GameVariantStatusPlayersUpdated