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").
diff --git a/.claude/vk-games-integration.md b/.claude/vk-games-integration.md
new file mode 100644
index 0000000..4859e87
--- /dev/null
+++ b/.claude/vk-games-integration.md
@@ -0,0 +1,173 @@
+# 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 (the friend-code invite uses it; `navigator.share` is absent
+ in the desktop VK iframe). **Used.**
+- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
+ blocked. **Used** as the copy-code / copy-link path.
+- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
+ "auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
+- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used —
+ the bottom home-bar safe area is handled by CSS `env(safe-area-inset-*)` (viewport-fit=cover).
+
+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.
+
+**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
+`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
+`vk.com/app#` form is eaten by the vk.com SPA (which owns the URL hash), and a
+`vk.com/app?hash=` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
+and an empty `hash`). So the friend-code invite link is just `vk.com/app` (`vkShareLink`, app id
+from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
+`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
+channel (e.g. an invite API) ever delivers a payload.
+
+## 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` plus `vkAppId`/
+ `vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
+ and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
+ `encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
+ `Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
+- **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**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
+ existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
+ the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
+
+## 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..265cfaf 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -31,7 +31,7 @@ on:
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env:
- DICT_VERSION: v1.3.0
+ DICT_VERSION: v1.3.1
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
@@ -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)
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..0857b04 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
@@ -121,7 +125,7 @@ and any incidental perpendicular words are ignored and not scored — while on i
standard Scrabble. English games are always standard and show no such toggle. In auto-match
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
formed by inviting players from the friend list (an invitation, like a friend code,
-is shareable as a Telegram deep link that opens it directly): the inviter chooses the
+is shareable as a Telegram or VK deep link that opens it directly): the inviter chooses the
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
expires after seven days.
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index f06af8c..ce733ac 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` коротким сообщением и кнопкой,
@@ -127,7 +131,7 @@ _Вход сейчас только через провайдера, поэто
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
-можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
+можно отправить deep-link'ом в Telegram или VK, который откроет его сразу): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
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/app.css b/ui/src/app.css
index 46a1d44..b6070bc 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -46,14 +46,16 @@
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
content-safe-area inset, 0 elsewhere. */
--tg-content-top: 0px;
- /* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
- --tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
- --tg-safe-top: 0px;
- /* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere —
- the screen pads its bottom and left/right edges by these so content clears the cut-outs. */
- --tg-safe-bottom: 0px;
- --tg-safe-left: 0px;
- --tg-safe-right: 0px;
+ /* Device safe-area top (the notch); inside Telegram TG's own nav controls sit between it and
+ --tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from
+ the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini
+ App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */
+ --tg-safe-top: env(safe-area-inset-top, 0px);
+ /* Device safe-area bottom / sides (home indicator; landscape notch) — the screen pads its bottom
+ and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */
+ --tg-safe-bottom: env(safe-area-inset-bottom, 0px);
+ --tg-safe-left: env(safe-area-inset-left, 0px);
+ --tg-safe-right: env(safe-area-inset-right, 0px);
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte
index 1fa31c0..043763f 100644
--- a/ui/src/components/Screen.svelte
+++ b/ui/src/components/Screen.svelte
@@ -14,6 +14,7 @@
scroll = true,
growNav = false,
column = false,
+ selfInset = false,
}: {
title: string;
back?: string;
@@ -21,6 +22,10 @@
children?: Snippet;
scroll?: boolean;
growNav?: boolean;
+ // selfInset: the content paints the bottom home-indicator inset itself (a tab-bar-less screen
+ // whose own bottom element owns the strip, e.g. the landscape game's left-panel controls), so
+ // the shell does not add the detached .content padding-bottom below it.
+ selfInset?: boolean;
// column lays the content out as a flex column so a child can own the vertical fit
// (the game makes only its board scroll while the score/rack/tab bar stay put).
column?: boolean;
@@ -87,7 +92,7 @@
- {@render children?.()}
+ {@render children?.()}
{#if tabbar}
{/if}
@@ -128,6 +133,11 @@
.content:last-child {
padding-bottom: var(--tg-safe-bottom, 0px);
}
+ /* selfInset: the content's own bottom element paints the home-indicator strip (the landscape
+ game's left-panel controls), so the shell does not add a detached padding strip below it. */
+ .content.selfinset:last-child {
+ padding-bottom: 0;
+ }
.tabbar {
flex: 0 0 auto;
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte
index a229155..7288220 100644
--- a/ui/src/game/Board.svelte
+++ b/ui/src/game/Board.svelte
@@ -105,18 +105,27 @@
prevZoom = on;
prevRecenter = rc;
if (landscape) {
- // Height-driven board in a wide viewport: pan so the focused cell is centred. The board
- // magnifies over the .scaler.land width/height transition, so the focus-centred scroll target
- // is only reachable once the board has grown — clamp it to the CURRENT scrollable size each
- // frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like
- // portrait's never settles here: zoom-out shrinks the board below the viewport, where it
- // stops overflowing.)
+ // A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
+ // HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
+ // moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
+ // until the board is wider than the viewport): the board positioning "in two steps". Drive
+ // BOTH axes by one progress q — how far the board has grown past the viewport width, the point
+ // at which a horizontal pan first becomes possible. Until then q is 0 and the board simply
+ // zooms centred; past it both axes pan together in one diagonal motion. The full-zoom focus
+ // target is finalSL/ST on zoom-in and the current position on zoom-out (where final is 0), so
+ // q running 1→0 unwinds the scroll before the board shrinks back below the viewport.
if (toggled || (recentered && on && f)) {
- const t0 = performance.now();
- let raf = requestAnimationFrame(function tick(now: number) {
- vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth));
- vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight));
- if (now - t0 < 320) raf = requestAnimationFrame(tick);
+ const scaler = vp.firstElementChild as HTMLElement | null;
+ const targetSL = on ? finalSL : vp.scrollLeft;
+ const targetST = on ? finalST : vp.scrollTop;
+ const span = Math.max(1, fullSide - clientW);
+ let raf = requestAnimationFrame(function tick() {
+ const side = scaler ? scaler.getBoundingClientRect().width : fullSide;
+ const q = Math.max(0, Math.min(1, (side - clientW) / span));
+ vp.scrollLeft = targetSL * q;
+ vp.scrollTop = targetST * q;
+ const settled = on ? side >= fullSide - 1 : side <= fitSide + 1;
+ if (!settled) raf = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(raf);
}
@@ -204,6 +213,69 @@
};
});
+ // Mouse drag-to-pan the zoomed board. Touch scrolls the overflow:auto viewport natively, but a
+ // mouse cannot drag-scroll it, so on desktop / the landscape iframe the magnified board could not
+ // be moved at all. Active only while zoomed, for a primary-button drag that does NOT start on a
+ // pending tile (those own their pointer for relocation), and only once the pointer passes a small
+ // threshold — so a plain click still places a rack tile or toggles zoom. A completed pan swallows
+ // the trailing click so it does not also act on the cell under the release.
+ $effect(() => {
+ const vp = viewport;
+ if (!vp) return;
+ let armed = false;
+ let panning = false;
+ let sx = 0;
+ let sy = 0;
+ let sl = 0;
+ let st = 0;
+ const onDown = (e: PointerEvent) => {
+ if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return;
+ if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return;
+ armed = true;
+ panning = false;
+ sx = e.clientX;
+ sy = e.clientY;
+ sl = vp.scrollLeft;
+ st = vp.scrollTop;
+ };
+ const onMove = (e: PointerEvent) => {
+ if (!armed) return;
+ const dx = e.clientX - sx;
+ const dy = e.clientY - sy;
+ if (!panning) {
+ if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
+ panning = true;
+ vp.setPointerCapture(e.pointerId);
+ }
+ vp.scrollLeft = sl - dx;
+ vp.scrollTop = st - dy;
+ };
+ const onUp = (e: PointerEvent) => {
+ armed = false;
+ if (!panning) return;
+ panning = false;
+ if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
+ // Swallow the click the drag would otherwise produce (capture phase, before it reaches the
+ // cell); self-remove on that click, and clean up on the next tick if none fired.
+ const swallow = (ev: Event) => {
+ ev.stopPropagation();
+ vp.removeEventListener('click', swallow, true);
+ };
+ vp.addEventListener('click', swallow, true);
+ setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
+ };
+ vp.addEventListener('pointerdown', onDown);
+ vp.addEventListener('pointermove', onMove);
+ vp.addEventListener('pointerup', onUp);
+ vp.addEventListener('pointercancel', onUp);
+ return () => {
+ vp.removeEventListener('pointerdown', onDown);
+ vp.removeEventListener('pointermove', onMove);
+ vp.removeEventListener('pointerup', onUp);
+ vp.removeEventListener('pointercancel', onUp);
+ };
+ });
+
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
@@ -287,6 +359,9 @@
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
board cannot be dragged past its content into empty space — it just stops at the edge. */
overscroll-behavior: none;
+ /* The zoom effect drives scrollLeft/Top itself while the board grows/shrinks; turn off the
+ browser's scroll-anchoring so it does not fight those writes mid-transition. */
+ overflow-anchor: none;
}
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
— a magnifying-glass zoom. */
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index 5170c49..91d88a4 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -1074,6 +1074,7 @@
column
scroll={false}
tabbar={landscape ? undefined : bottomBar}
+ selfInset={landscape}
>
{#if view}
{#if landscape}
@@ -1544,6 +1545,14 @@
.make:disabled {
opacity: 0.4;
}
+ /* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
+ button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
+ overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
+ so it sits inside the freed slot with its right edge level with the rack's right edge — the
+ mirror of the first tile's left edge. */
+ .game-land .make {
+ width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
+ }
/* The move-history header: leave (active) / export (finished) on the left, comms on the
right, icon-only. Sticky so it stays atop the scrolling move list. */
.hhead {
@@ -1730,8 +1739,10 @@
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
gap: 4px;
/* The left-column children carry their own horizontal --pad (matching portrait), so the
- grid only pads its right edge; the board centres in the right pane. */
- padding: 4px var(--pad) 6px 0;
+ grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
+ controls bar and the board reach the screen edge — the controls bar owns the home-indicator
+ inset (see the .leftpane .tabbar rule), the board runs under the thin indicator. */
+ padding: 4px var(--pad) 0 0;
overflow: hidden;
}
.leftpane {
@@ -1739,6 +1750,13 @@
flex-direction: column;
min-height: 0;
}
+ /* The home-indicator inset on the controls side: the left panel's controls bar paints its own
+ chrome (--bg-elev) into it so the buttons clear the cut-out (the shell's detached content strip
+ is suppressed here via Screen selfInset); the board pane runs to the edge under the thin
+ indicator. */
+ .game-land .leftpane :global(.tabbar) {
+ padding-bottom: calc(8px + var(--tg-safe-bottom, 0px));
+ }
.rightpane {
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte
index ec81591..71ce0d4 100644
--- a/ui/src/game/Rack.svelte
+++ b/ui/src/game/Rack.svelte
@@ -112,12 +112,32 @@
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
viewport-width measure that would overflow seven tiles in a column. */
+ .rack.landscape {
+ /* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
+ .scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
+ resolves unreliably, so the rack — which has a definite width — is the query container. A
+ fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
+ dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
+ container-type: inline-size;
+ }
.rack.landscape .tile {
- flex: 1 1 0;
- width: auto;
- min-width: 0;
+ /* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
+ so placing a tile leaves the remaining ones put instead of growing them to refill the row —
+ matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
+ tiles leave. */
+ flex: 0 0 auto;
+ width: calc((100% - 6 * 5px) / 7);
max-width: 46px;
}
+ .rack.landscape .letter {
+ font-size: 6cqw;
+ }
+ .rack.landscape .val {
+ font-size: 3.1cqw;
+ }
+ .rack.landscape .star {
+ font-size: 7.6cqw;
+ }
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile {
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..2a9de50 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, vkStartParam, vkOnScheme, vkOnInsets } 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,31 @@ 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()) {
+ // Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
+ // VK mobile webview's prefers-color-scheme does not track it).
+ void vkOnScheme((scheme) => {
+ if (app.theme === 'auto') applyTheme(scheme);
+ });
+ // Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
+ // CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
+ void vkOnInsets((i) => {
+ const root = document.documentElement;
+ root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
+ root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
+ root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
+ root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
+ });
+ await vkInit();
+ await bootVK();
+ app.ready = true;
+ return;
+ }
+
const saved = await loadSession();
if (saved) {
await adoptSession(saved);
@@ -674,10 +700,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 +740,54 @@ 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));
+ // VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
+ // (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
+ // confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
+ // launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
+ // deep-link channel, and routes the lobby for an empty payload today.
+ if (!app.blocked) await routeStartParam(vkStartParam());
+ 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/deeplink.test.ts b/ui/src/lib/deeplink.test.ts
index 81a4fed..becb5f1 100644
--- a/ui/src/lib/deeplink.test.ts
+++ b/ui/src/lib/deeplink.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
+import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink, vkShareLink } from './deeplink';
describe('parseStartParam', () => {
it('classifies game / invitation / friend code', () => {
@@ -37,6 +37,20 @@ describe('shareLink', () => {
});
});
+describe('vkShareLink', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('returns null outside a VK launch (no vk_app_id)', () => {
+ vi.stubGlobal('location', { search: '' });
+ expect(vkShareLink()).toBeNull();
+ });
+
+ it('returns the plain vk.com/app link (VK drops a custom query payload, so the code is not carried)', () => {
+ vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
+ expect(vkShareLink()).toBe('https://vk.com/app6736218');
+ });
+});
+
describe('botUsername', () => {
afterEach(() => vi.unstubAllEnvs());
diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts
index 199eaa8..19a2e5b 100644
--- a/ui/src/lib/deeplink.ts
+++ b/ui/src/lib/deeplink.ts
@@ -6,6 +6,8 @@
// f<6-digit code> redeem that friend code
// An empty or unrecognised parameter opens the lobby.
+import { vkAppId } from './vk';
+
export type DeepLink =
| { kind: 'lobby' }
| { kind: 'game'; id: string }
@@ -74,3 +76,16 @@ export function shareLink(param: string): string | null {
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
}
+
+/**
+ * vkShareLink returns the VK Mini App link (https://vk.com/app) to share on VK, or null when the
+ * VK app id is unknown (outside a VK launch). VK forwards no custom payload through the app link to
+ * the iframe — the '#' form is eaten by the vk.com SPA and a '?' query is dropped (confirmed on the
+ * contour: only the signed vk_* params arrive) — so the link cannot carry the friend code; the
+ * recipient enters the copied code by hand.
+ */
+export function vkShareLink(): string | null {
+ const id = vkAppId();
+ if (!id) return null;
+ return `https://vk.com/app${id}`;
+}
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index bbfe5bf..80abbca 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -301,7 +301,7 @@ export const ru: Record = {
'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.addFriendShort': 'В друзья?',
- 'game.blockShort': 'Блокируем?',
+ 'game.blockShort': 'В бан?',
'time.minutes': '{n} мин',
'time.hours': '{n} ч',
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..400fb84
--- /dev/null
+++ b/ui/src/lib/vk.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam } 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);
+ });
+});
+
+describe('vk launch params', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('reads the VK app id from the launch query', () => {
+ vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_app_id=6736218&sign=x' });
+ expect(vkAppId()).toBe('6736218');
+ });
+
+ it('reads the direct-link deep-link payload from the hash query param', () => {
+ vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x&hash=f123456' });
+ expect(vkStartParam()).toBe('f123456');
+ });
+
+ it('returns empty app id / start param when absent', () => {
+ vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x' });
+ expect(vkAppId()).toBe('');
+ expect(vkStartParam()).toBe('');
+ });
+});
diff --git a/ui/src/lib/vk.ts b/ui/src/lib/vk.ts
new file mode 100644
index 0000000..6f49f21
--- /dev/null
+++ b/ui/src/lib/vk.ts
@@ -0,0 +1,162 @@
+// 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 '';
+ }
+}
+
+/**
+ * vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL —
+ * so the app can build its own vk.com/app links without a VK API call — or '' outside a VK launch.
+ */
+export function vkAppId(): string {
+ if (typeof location === 'undefined') return '';
+ return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? '';
+}
+
+/**
+ * vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a
+ * vk.com/app# link to the app as the `hash` query parameter (it is also in location.hash,
+ * but that collides with the app's hash router, so the query parameter is the safe source). Empty when
+ * the launch carried no deep link.
+ */
+export function vkStartParam(): string {
+ if (typeof location === 'undefined') return '';
+ return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? '';
+}
+
+/**
+ * vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for
+ * navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was
+ * handled, false on any failure or outside VK.
+ */
+export async function vkShare(link: string): Promise {
+ try {
+ await (await bridge()).send('VKWebAppShare', { link });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
+ * where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
+ */
+export async function vkCopyText(text: string): Promise {
+ try {
+ await (await bridge()).send('VKWebAppCopyText', { text });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped
+ * 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's
+ * "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme.
+ * A no-op outside VK.
+ */
+export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise {
+ try {
+ const b = await bridge();
+ b.subscribe((e) => {
+ const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail;
+ if (detail?.type !== 'VKWebAppUpdateConfig') return;
+ const scheme = detail.data?.scheme ?? detail.data?.appearance ?? '';
+ handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light');
+ });
+ } catch {
+ // Outside VK / bridge unavailable: leave the app on its own theme.
+ }
+}
+
+/** VKInsets is the device safe-area the VK client reports (px). */
+export interface VKInsets {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+}
+
+/**
+ * vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change.
+ * VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK
+ * mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them
+ * from the bridge to clear the home bar (notably on Android). A no-op outside VK.
+ */
+export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise {
+ try {
+ const b = await bridge();
+ b.subscribe((e) => {
+ const d = (e as { detail?: { type?: string; data?: { insets?: Partial } } }).detail;
+ if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return;
+ const i = d.data?.insets;
+ if (!i) return;
+ handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 });
+ });
+ } catch {
+ // Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
+ }
+}
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 @@