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