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
+47
View File
@@ -154,6 +154,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
}
}
// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
// language, display name and time zone from the launch fields / detected offset, records
// the vk identity as confirmed (a platform identity), and never overwrites an existing
// account on a later launch. It also exercises the widened identities.kind CHECK — a
// 'vk' row must insert.
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "vk-" + uuid.NewString()
acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
if err != nil {
t.Fatalf("provision vk: %v", err)
}
if !created {
t.Error("created = false on first contact, want true")
}
if acc.PreferredLanguage != "ru" {
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
}
if acc.DisplayName != "Иван Петров" {
t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
}
if acc.TimeZone != "+03:00" {
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
}
// A VK identity is a platform identity: confirmed on insert.
if !identityConfirmed(t, account.KindVK, ext) {
t.Error("vk identity must be confirmed")
}
// A later launch with different fields returns the same account, unchanged.
again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
if err != nil {
t.Fatalf("re-provision vk: %v", err)
}
if created {
t.Error("created = true on a repeat launch, want false")
}
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
}
}
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
// distinct from the unset "UTC" default), a guest is seeded the same way, and a