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
@@ -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")
}
}