Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master

This commit was merged in pull request #144.
This commit is contained in:
2026-06-30 05:37:43 +00:00
53 changed files with 1617 additions and 84 deletions
+89
View File
@@ -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 <base>...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").
+173
View File
@@ -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: <https://dev.vk.com/> (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 <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
(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<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
`vk.com/app<id>?hash=<payload>` 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<id>` (`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=<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 ~2472h); 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 — <https://github.com/VKCOM/vk-bridge>
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
+4 -1
View File
@@ -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 }}
+2
View File
@@ -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
+46 -5
View File
@@ -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
}
@@ -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)
}
})
}
}
@@ -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",
@@ -142,6 +142,11 @@
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section>
{{end}}
{{if .VKID}}
<section class="panel"><h2>VK</h2>
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
</section>
{{end}}
<section class="panel"><h2>Games</h2>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
+10 -6
View File
@@ -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.
+47
View File
@@ -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
@@ -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])));
+1
View File
@@ -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)
@@ -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))
+31
View File
@@ -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"`
+6
View File
@@ -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=
+2 -2
View File
@@ -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
+4
View File
@@ -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
+16 -8
View File
@@ -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
+6 -2
View File
@@ -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 (24) 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.
+7 -3
View File
@@ -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, который откроет его сразу): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
+11 -3
View File
@@ -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) |
+1 -1
View File
@@ -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,
+17
View File
@@ -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.
+5
View File
@@ -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(),
+8 -7
View File
@@ -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
+45 -2
View File
@@ -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
@@ -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")
}
}
+72
View File
@@ -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
}
+111
View File
@@ -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)
}
})
}
}
+14
View File
@@ -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).
+82
View File
@@ -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()
}
+1
View File
@@ -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": {
+22
View File
@@ -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: {}
+10 -8
View File
@@ -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);
+11 -1
View File
@@ -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 @@
<div class="screen">
<Header {title} {back} grow={growNav} />
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
<main class="content" class:scroll class:fill={!growNav} class:column class:selfinset={selfInset}>{@render children?.()}</main>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>
{/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
+86 -11
View File
@@ -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. */
+20 -2
View File
@@ -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
+23 -3
View File
@@ -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 {
+1
View File
@@ -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';
@@ -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);
}
}
+75 -9
View File
@@ -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<void> {
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<void> {
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<void> {
}
/**
* 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<void> {
async function bootVK(): Promise<void> {
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<void> {
app.bootError = false;
app.ready = false;
await bootTelegram(telegramLaunch());
if (onVKPath()) {
await vkInit();
await bootVK();
} else {
await bootTelegram(telegramLaunch());
}
app.ready = true;
}
+4
View File
@@ -58,6 +58,10 @@ export type Unsubscribe = () => void;
export interface GatewayClient {
// --- auth (unauthenticated) ---
authTelegram(initData: string): Promise<Session>;
/** 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<Session>;
authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>;
+8
View File
@@ -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', () => {
+12
View File
@@ -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);
+15 -1
View File
@@ -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());
+15
View File
@@ -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<id>) 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}`;
}
+1 -1
View File
@@ -301,7 +301,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.addFriendShort': 'В друзья?',
'game.blockShort': 'Блокируем?',
'game.blockShort': 'В бан?',
'time.minutes': '{n} мин',
'time.hours': '{n} ч',
+3
View File
@@ -142,6 +142,9 @@ export class MockGateway implements GatewayClient {
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
return { ...SESSION, isGuest: false };
}
async authVK(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
async authGuest(): Promise<Session> {
return { ...SESSION };
}
+3
View File
@@ -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())));
},
+51
View File
@@ -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('');
});
});
+162
View File
@@ -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<void> {
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<string> {
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<id> 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<id>#<payload> 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<boolean> {
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<boolean> {
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<void> {
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<void> {
try {
const b = await bridge();
b.subscribe((e) => {
const d = (e as { detail?: { type?: string; data?: { insets?: Partial<VKInsets> } } }).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.
}
}
+5 -4
View File
@@ -1,8 +1,9 @@
<script lang="ts">
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was
// briefly down during a deploy). Inside Telegram there is no web login to fall back to, so this
// terminal screen offers a manual Retry that re-runs the launch (app.svelte retryTelegramBoot).
import { retryTelegramBoot } from '../lib/app.svelte';
// briefly down during a deploy). Inside a Mini App (Telegram or VK) there is no web login to fall
// back to, so this terminal screen offers a manual Retry that re-runs the launch (app.svelte
// retryMiniAppBoot).
import { retryMiniAppBoot } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
let retrying = $state(false);
@@ -10,7 +11,7 @@
if (retrying) return;
retrying = true;
try {
await retryTelegramBoot();
await retryMiniAppBoot();
} finally {
retrying = false;
}
+17 -4
View File
@@ -6,8 +6,9 @@
import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { friendCodeParam, shareLink, vkShareLink } from '../lib/deeplink';
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
import { insideVK, vkCopyText, vkShare } from '../lib/vk';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
let friends = $state<AccountRef[]>([]);
@@ -140,6 +141,11 @@
async function copyCode() {
if (!code) return;
// Inside the VK iframe navigator.clipboard is blocked, so copy via VK Bridge there.
if (insideVK()) {
if (await vkCopyText(code.code)) showToast(t('friends.codeCopied'));
return;
}
try {
await navigator.clipboard.writeText(code.code);
showToast(t('friends.codeCopied'));
@@ -153,6 +159,13 @@
// copies the link to the clipboard.
async function shareInvite(url: string) {
const text = t('friends.inviteText');
// Inside VK navigator.share is absent (desktop iframe) and the clipboard is blocked: share via VK
// Bridge, falling back to a VK-Bridge clipboard copy if the share is dismissed or unavailable.
if (insideVK()) {
if (await vkShare(url)) return;
if (await vkCopyText(url)) showToast(t('friends.linkCopied'));
return;
}
if (shareTelegramLink(url, text)) return;
if (typeof navigator !== 'undefined' && navigator.share) {
try {
@@ -188,7 +201,7 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
{@const shareUrl = insideVK() ? vkShareLink() : shareLink(friendCodeParam(code.code))}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -197,8 +210,8 @@
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{#if shareUrl}
<button type="button" class="link" onclick={() => shareInvite(shareUrl)}>{t('friends.shareTelegram')}</button>
{/if}
</div>
{:else}