feat(vk): embed the game as a VK Mini App
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/
entry, authenticates from VK's signed launch parameters, and provisions a
'vk' platform identity — the minimum to run the game in VK test mode.

- Gateway verifies the launch signature in-process (internal/vkauth:
  HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET,
  base64url) — a pure offline check, no side-service. New auth.vk op
  (gated on the secret), backendclient.VKAuth, /vk/ SPA mount.
- Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity
  kind widened to include 'vk' (migration 00005, expand-contract).
- UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot
  dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits
  the name from the signed params, so the client reads it via
  VKWebAppGetUserInfo as an unsigned display seed.
- Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through
  compose + .env.example + CI (TEST_) + prod-deploy (PROD_).
- Admin console: surface the VK user id (link to the VK profile) next to
  the Telegram id on the user card.
- Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK
  integration reference under .claude/.

Signature algorithm verified against dev.vk.com plus independent Node/Python
references and a %2C edge-case vector.
This commit is contained in:
Ilia Denisov
2026-06-27 11:37:31 +02:00
parent 13c22734ee
commit 65c194264c
43 changed files with 1175 additions and 50 deletions
+157
View File
@@ -0,0 +1,157 @@
# VK Mini App / VK Games — integration reference
Captured research + our implementation map, so a future session does not need to re-fetch
the VK docs. Authoritative external source: <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 (deferred).
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile).
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme + theme changes.
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
it **lazily** so the pure URL helpers stay node-test-importable.
## 5. Test mode (to verify before moderation)
1. App already registered (we have the App ID).
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<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`),
`app.svelte.ts` `bootVK` + the `/vk/` dispatch branch + shared `retryMiniAppBoot`,
`codec.ts` `encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`.
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
(`PROD_…` secret, deploy-main).
- **Deferred** (not in the test-mode MVP): `VKWebAppShare`, deep-links (`vk_ref`/startapp),
VK theme/viewport forcing, `VITE_VK_APP_ID` build arg, payments, account-linking a vk identity
to an existing account.
## Sources
- VKCOM/vk-bridge — <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>
+3
View File
@@ -261,6 +261,9 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }} TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_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 }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
+2
View File
@@ -82,6 +82,7 @@ jobs:
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} 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_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }} PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }} PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
@@ -136,6 +137,7 @@ jobs:
export APP_VERSION='$TAG' export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN' export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export GATEWAY_ABUSE_BAN_ENABLED='true' export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt 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" "scrabble/backend/internal/postgres/jet/backend/table"
) )
// Identity kinds recognised by the backend. Email is modelled as an identity // Identity kinds recognised by the backend. Telegram and VK are platform identities,
// alongside platform identities; its confirmed flag is driven by the email // auto-confirmed on first contact. Email is modelled as an identity alongside them; its
// confirm-code flow. Robot is a synthetic kind: each pooled // confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
// robot opponent is a durable account bound to one robot identity. // each pooled robot opponent is a durable account bound to one robot identity.
const ( const (
KindTelegram = "telegram" KindTelegram = "telegram"
KindVK = "vk"
KindEmail = "email" KindEmail = "email"
KindRobot = "robot" KindRobot = "robot"
) )
@@ -185,6 +186,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
return acc, created, err 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, // provision finds the account for (kind, externalID) or creates it with seed,
// collapsing a concurrent-create race on the identity unique constraint into a // collapsing a concurrent-create race on the identity unique constraint into a
// re-read of the winner's account. // re-read of the winner's account.
@@ -258,6 +281,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
return seed 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. // 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) { func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns). 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.Kind,
table.Identities.ExternalID, table.Identities.ExternalID,
table.Identities.Confirmed, 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 { if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
return err return err
} }
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName) 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"}, {"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"}, {"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", 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", 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", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", {"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
@@ -142,6 +142,11 @@
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}} {{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section> </section>
{{end}} {{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> <section class="panel"><h2>Games</h2>
<table class="list"> <table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead> <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 HintBalance int
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the // 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. // server's maxHintGrant), passed through so the policy value lives in one place.
HintGrantMax int HintGrantMax int
CreatedAt string CreatedAt string
HasStats bool HasStats bool
Stats StatsRow Stats StatsRow
Identities []IdentityRow Identities []IdentityRow
Games []GameRow 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 TelegramID string
VKID string
ConnectorEnabled bool ConnectorEnabled bool
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think // 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. // 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 // TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
// valid detected offset is stored verbatim (even "+00:00", which is deliberately // 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 // 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 { if s.sessions != nil && s.accounts != nil {
in := s.internal in := s.internal
in.POST("/sessions/telegram", s.handleTelegramAuth) in.POST("/sessions/telegram", s.handleTelegramAuth)
in.POST("/sessions/vk", s.handleVKAuth)
in.POST("/sessions/guest", s.handleGuestAuth) in.POST("/sessions/guest", s.handleGuestAuth)
in.POST("/sessions/email/request", s.handleEmailRequest) in.POST("/sessions/email/request", s.handleEmailRequest)
in.POST("/sessions/email/login", s.handleEmailLogin) 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 { if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg 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 { if games, err := s.games.ListForAccount(ctx, id); err == nil {
for _, g := range games { for _, g := range games {
view.Games = append(view.Games, gameRow(g)) 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) 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. // pushTargetRequest asks for a user's out-of-app push routing data by account id.
type pushTargetRequest struct { type pushTargetRequest struct {
UserID string `json:"user_id"` 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_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= 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 # 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 # 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 # the gateway; the catch-all — notably the public landing at / — goes to the
# static landing container, so stray traffic never reaches the Go edge. # static landing container, so stray traffic never reaches the Go edge.
# Mirrors ../galaxy-game's /_gm model. # Mirrors ../galaxy-game's /_gm model.
@@ -53,7 +53,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any # 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 # 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). # 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 { handle @gateway {
reverse_proxy gateway:8081 { reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot header_up -X-Scrabble-Honeypot
+4
View File
@@ -147,6 +147,10 @@ services:
GATEWAY_BACKEND_GRPC_ADDR: backend:9090 GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Telegram auth validates against the home validator (plaintext, internal). # Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091 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 # 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 # 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 # 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` - The gateway validates the originating credential **once** — Telegram `initData`
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token — (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 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`) Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
and display name (§4). The validator runs on the main host and never reaches the Bot `vk_language`) and display name (§4; VK omits the name from the signed params, so the client
API, so login does not depend on Telegram or the remote bot being up (§10, §12). 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 - **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 game channel), split into a home **validator** and a remote **bot** that share the
token. `ValidateInitData` (the validator) validates `initData` against that single 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 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 **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 (`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 — no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away); a stray hit on the gateway's `/` of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
308-redirects to `/app/`. The **landing** ships in its own static container: the 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, `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 `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 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 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 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 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 catch-all — notably the landing at `/` — goes to the landing container. The
**Telegram validator** runs as a separate container with **no public ingress**, **Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
+5 -1
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. 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 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 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. (it follows the system scheme, not the saved preference); its language choice is saved.
### Identity & sessions ### 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 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 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. **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 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 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 player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
+6 -2
View File
@@ -23,7 +23,7 @@ top-1 подсказку, безлимитную проверку слова с
и ограничивает частоту повторов. и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из `/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется. системной настройки, а не из сохранённой), выбор языка сохраняется.
### Личность и сессии ### Личность и сессии
@@ -37,7 +37,11 @@ Mini App** авторизует по подписанным `initData` плат
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
так как VK не кладёт имя в подписанный запуск). Тот же экран тихого повтора «не удалось
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой, **промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
+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 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 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 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 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; 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 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/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge) 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/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 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` `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. 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`, `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; `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
live events live events
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call | | `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` | | `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_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_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_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) | | `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)") 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{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
+17
View File
@@ -201,6 +201,23 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
return out, err 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 // 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 // external_id (empty when they have no Telegram identity), preferred language, and
// whether they confined notifications to the in-app stream. // 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 // plaintext, internal). The gateway calls it to validate Mini App initData and
// Login Widget data. Empty disables the telegram auth path. // Login Widget data. Empty disables the telegram auth path.
ValidatorAddr string 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 // 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). // empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
BotLink BotLinkConfig BotLink BotLinkConfig
@@ -169,6 +173,7 @@ func Load() (Config, error) {
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
SessionCacheMax: defaultSessionCacheMax, SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(), RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(), 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. // does not serve the app shell at the operator path.
mux.Handle("/_gm/", http.NotFoundHandler()) mux.Handle("/_gm/", http.NotFoundHandler())
} }
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram // The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below // App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and // §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
// each mount falls back to the app shell (index.html) for the hash router. The // priority, and each mount falls back to the app shell (index.html) for the hash
// public landing lives in its own static container behind the contour caddy, // router. The public landing lives in its own static container behind the contour
// so the catch-all redirects a stray root hit to the app shell — which // caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
// keeps a local no-caddy run usable. // local no-caddy run usable.
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html")) 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("/app/", webui.Handler("/app/", "index.html"))
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect)) mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a // 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/backendclient"
"scrabble/gateway/internal/connector" "scrabble/gateway/internal/connector"
"scrabble/gateway/internal/vkauth"
fb "scrabble/pkg/fbs/scrabblefb" fb "scrabble/pkg/fbs/scrabblefb"
) )
// Message types in the vertical slice. // Message types in the vertical slice.
const ( const (
MsgAuthTelegram = "auth.telegram" MsgAuthTelegram = "auth.telegram"
MsgAuthVK = "auth.vk"
MsgAuthGuest = "auth.guest" MsgAuthGuest = "auth.guest"
MsgAuthEmailReq = "auth.email.request" MsgAuthEmailReq = "auth.email.request"
MsgAuthEmailLogin = "auth.email.login" MsgAuthEmailLogin = "auth.email.login"
@@ -89,8 +91,9 @@ type TelegramValidator interface {
// NewRegistry builds the slice's message-type catalog over the backend client. // 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 // The Telegram auth op is registered only when a validator is supplied (the
// connector is configured); otherwise auth.telegram is simply unknown. // connector is configured); otherwise auth.telegram is simply unknown. Optional ops
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry { // (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)} r := &Registry{ops: make(map[string]Op)}
if tg != nil { if tg != nil {
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)} 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} r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
registerSocialOps(r, backend) registerSocialOps(r, backend)
registerLinkOps(r, backend, tg) registerLinkOps(r, backend, tg)
for _, opt := range opts {
opt(r, backend)
}
return r 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. // Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) { func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType] op, ok := r.ops[messageType]
@@ -146,6 +167,9 @@ func DomainCode(err error) (string, bool) {
if errors.Is(err, connector.ErrInvalidInitData) { if errors.Is(err, connector.ErrInvalidInitData) {
return "invalid_init_data", true return "invalid_init_data", true
} }
if errors.Is(err, vkauth.ErrInvalid) {
return "invalid_vk_params", true
}
if errors.Is(err, connector.ErrInvalidLoginWidget) { if errors.Is(err, connector.ErrInvalidLoginWidget) {
return "invalid_login_widget", true 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 { func authGuestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
// The guest bootstrap historically carried no payload; the detected zone is // 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; 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 // GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
// preferred-language hint; browser_tz is the detected UTC offset seeded into the // preferred-language hint; browser_tz is the detected UTC offset seeded into the
// guest account's time zone (see TelegramLoginRequest.browser_tz). // 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", "@bufbuild/protobuf": "^2.12.0",
"@connectrpc/connect": "^2.1.0", "@connectrpc/connect": "^2.1.0",
"@connectrpc/connect-web": "^2.1.0", "@connectrpc/connect-web": "^2.1.0",
"@vkontakte/vk-bridge": "^3.0.2",
"flatbuffers": "^25.9.23" "flatbuffers": "^25.9.23"
}, },
"devDependencies": { "devDependencies": {
+22
View File
@@ -17,6 +17,9 @@ importers:
'@connectrpc/connect-web': '@connectrpc/connect-web':
specifier: ^2.1.0 specifier: ^2.1.0
version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.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: flatbuffers:
specifier: ^25.9.23 specifier: ^25.9.23
version: 25.9.23 version: 25.9.23
@@ -413,6 +416,9 @@ packages:
svelte: ^5.0.0 svelte: ^5.0.0
vite: ^6.0.0 vite: ^6.0.0
'@swc/helpers@0.5.23':
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
'@types/chai@5.2.3': '@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -462,6 +468,9 @@ packages:
'@vitest/utils@3.2.6': '@vitest/utils@3.2.6':
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} 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: acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
@@ -689,6 +698,9 @@ packages:
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.4.5: typescript@5.4.5:
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@@ -1022,6 +1034,10 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@swc/helpers@0.5.23':
dependencies:
tslib: 2.8.1
'@types/chai@5.2.3': '@types/chai@5.2.3':
dependencies: dependencies:
'@types/deep-eql': 4.0.2 '@types/deep-eql': 4.0.2
@@ -1086,6 +1102,10 @@ snapshots:
loupe: 3.2.1 loupe: 3.2.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
'@vkontakte/vk-bridge@3.0.2':
dependencies:
'@swc/helpers': 0.5.23
acorn@8.16.0: {} acorn@8.16.0: {}
aria-query@5.3.1: {} aria-query@5.3.1: {}
@@ -1318,6 +1338,8 @@ snapshots:
tinyspy@4.0.4: {} tinyspy@4.0.4: {}
tslib@2.8.1: {}
typescript@5.4.5: {} typescript@5.4.5: {}
typescript@5.9.3: {} typescript@5.9.3: {}
+1
View File
@@ -71,5 +71,6 @@ export { TargetRequest } from './scrabblefb/target-request.js';
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js'; export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
export { TileRecord } from './scrabblefb/tile-record.js'; export { TileRecord } from './scrabblefb/tile-record.js';
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.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 { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.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);
}
}
+55 -9
View File
@@ -32,6 +32,7 @@ import {
telegramCloudGet, telegramCloudGet,
telegramCloudSet, telegramCloudSet,
} from './telegram'; } from './telegram';
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName } from './vk';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs'; import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
@@ -664,6 +665,17 @@ export async function bootstrap(): Promise<void> {
return; 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(); const saved = await loadSession();
if (saved) { if (saved) {
await adoptSession(saved); await adoptSession(saved);
@@ -674,10 +686,10 @@ export async function bootstrap(): Promise<void> {
app.ready = true; app.ready = true;
} }
// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall // Inside a Mini App the only identity is the platform session (Telegram or VK), so a failed launch
// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a // must never fall back to the web login screen. A transient backend outage (a deploy rolling over)
// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the // is retried a few times in silence; only then does the boot-error screen surface, from which Retry
// same path (retryTelegramBoot). // re-runs the same path (retryMiniAppBoot).
const TELEGRAM_BOOT_RETRIES = 2; const TELEGRAM_BOOT_RETRIES = 2;
const TELEGRAM_BOOT_RETRY_MS = 1200; const TELEGRAM_BOOT_RETRY_MS = 1200;
@@ -714,14 +726,48 @@ async function bootTelegram(launch: TelegramLaunch): Promise<void> {
} }
/** /**
* retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It * bootVK authenticates a VK Mini App launch from the signed launch parameters in the URL, seeding a
* clears the error and shows the loading state again, then runs the same retrying boot; on success * brand-new account's display name from VKWebAppGetUserInfo. Like bootTelegram it retries a few
* the app renders normally, otherwise the boot-error screen returns. * 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));
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.bootError = false;
app.ready = false; app.ready = false;
await bootTelegram(telegramLaunch()); if (onVKPath()) {
await vkInit();
await bootVK();
} else {
await bootTelegram(telegramLaunch());
}
app.ready = true; app.ready = true;
} }
+4
View File
@@ -58,6 +58,10 @@ export type Unsubscribe = () => void;
export interface GatewayClient { export interface GatewayClient {
// --- auth (unauthenticated) --- // --- auth (unauthenticated) ---
authTelegram(initData: string): Promise<Session>; 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>; authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string): Promise<void>; authEmailRequest(email: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>; authEmailLogin(email: string, code: string): Promise<Session>;
+8
View File
@@ -30,6 +30,7 @@ import {
encodeTarget, encodeTarget,
encodeTelegramLogin, encodeTelegramLogin,
encodeUpdateProfile, encodeUpdateProfile,
encodeVKLogin,
} from './codec'; } from './codec';
describe('codec', () => { describe('codec', () => {
@@ -94,6 +95,13 @@ describe('codec', () => {
); );
expect(email.email()).toBe('a@example.com'); expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00'); 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', () => { 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)); 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 { export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
const b = new Builder(64); const b = new Builder(64);
const l = b.createString(locale); const l = b.createString(locale);
+3
View File
@@ -142,6 +142,9 @@ export class MockGateway implements GatewayClient {
if (initData.includes('bootfail')) throw new GatewayError('unavailable'); if (initData.includes('bootfail')) throw new GatewayError('unavailable');
return { ...SESSION, isGuest: false }; return { ...SESSION, isGuest: false };
} }
async authVK(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
async authGuest(): Promise<Session> { async authGuest(): Promise<Session> {
return { ...SESSION }; return { ...SESSION };
} }
+3
View File
@@ -65,6 +65,9 @@ export function createTransport(baseUrl: string): GatewayClient {
async authTelegram(initData) { async authTelegram(initData) {
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); 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) { async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset()))); return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
}, },
+31
View File
@@ -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);
});
});
+66
View File
@@ -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<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 '';
}
}
+5 -4
View File
@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was // 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 // briefly down during a deploy). Inside a Mini App (Telegram or VK) there is no web login to fall
// terminal screen offers a manual Retry that re-runs the launch (app.svelte retryTelegramBoot). // back to, so this terminal screen offers a manual Retry that re-runs the launch (app.svelte
import { retryTelegramBoot } from '../lib/app.svelte'; // retryMiniAppBoot).
import { retryMiniAppBoot } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
let retrying = $state(false); let retrying = $state(false);
@@ -10,7 +11,7 @@
if (retrying) return; if (retrying) return;
retrying = true; retrying = true;
try { try {
await retryTelegramBoot(); await retryMiniAppBoot();
} finally { } finally {
retrying = false; retrying = false;
} }